clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

Add a show at once, not behind the channels' catalogs, and refresh Podcasts for the newest

FelineStateMachine committed 7c75386
Adding a show waited for yt-dlp's one listing job at a time, which the
YouTube channels hold for minutes while they list a whole catalog for their
schedules. Its two minutes ran out while it waited, so it failed the moment
its turn came, and pressing Add again only queued more. Now adding a show
lists it at once, a few seconds' work, as a search does. The background
listings and lookups still take turns, but their time starts when their
turn does, so a busy channel no longer fails them.

Podcasts has Refresh under the shows in the rail (also R), for someone who
wants the newest now rather than at the next listing, every 6 hours while
YouTube's feeds are down. The server lists every show again, a few at once,
and the app says how many episodes are new and when every show was last
checked.

In Settings, Add says it's still adding when pressed again, a channel found
says how many subscribers it has as YouTube does (3.39M), and one already a
show says Added. Names from YouTube lose the spaces around them.
commit
7c7538696d7df66799a95ff8c3c2fe3ab17da0e8
parent
6129c5cc215d1cf96465e8d95f51f0b14a43c950
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
feat risk score 1.9 of 3, Jev's estimate
17 files changed, +269 -40
MREADME.md11++-
Mapp.go9+++
Mfrontend/dist/index.html8++-
Mfrontend/dist/js/podcasts.js104+++++++++++++++++++++++++++---
Mfrontend/dist/style.css6++
Mfrontend/dist/web.js1+
Mfrontend/wailsjs/go/main/App.d.ts2+
Mfrontend/wailsjs/go/main/App.js4+
Mfrontend/wailsjs/go/models.ts2+
Minternal/api/api.go4+
Minternal/api/api_test.go9+++
Minternal/api/client.go6++
Minternal/podcast/podcast.go21++++--
Minternal/podcast/podcast_test.go52++++++++++++++-
Minternal/podcast/update.go54++++++++++++---
Minternal/service/podcasts.go10+++
Minternal/youtube/listing.go6+-
M README.md +8 -3
@@ -165,7 +165,7 @@ had.
165165 | --- | --- |166166 | G | Guide: arrows move, and Up and Down wrap round the channels. The filters (Favorites, Sports...; also `[` `]`) are Down from the top bar, which is Left from the earliest program. Enter watches what's on, or shows a program to come's actions; hold Enter, or I, for any program's: watch, record (which opens to this one, every episode or new only), favorite, hide. Home jumps to now, R / Shift R / N record, Shift F favorite, H hide (U undoes) |167167 | D | Recordings: Left (or `[` `]`) for Library, Coming up, Series. Enter shows a recording's actions: resume, from the start, watched, delete (press twice); for one recording now, watch from the start, 30 more minutes, stop (keeping it) and delete; Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |168-| P | Podcasts: video podcasts from YouTube, on demand. The rail has Latest (every show's newest), Continue and each show; Enter shows an episode's actions: play or resume, from the start, watched. Shows are added in Settings, Podcasts, by search or a channel's or playlist's address |168+| P | Podcasts: video podcasts from YouTube, on demand. The rail has Latest (every show's newest), Continue and each show, and under them Refresh (also R), which looks for new episodes now; Enter shows an episode's actions: play or resume, from the start, watched. Shows are added in Settings, Podcasts, by search or a channel's or playlist's address |169169 | W | Weather: current conditions, alerts, the next 24 hours, 7-day, radar loop and satellite; Up and Down move through them, Left to watch the weather channel |170170 | `,` or S | More: Settings, a tab for each area in the rail (Display, Watching, Channels, Reception, Recordings, Server, About). Up and Down pick a tab, Enter goes in, Back returns to the tabs; Page Up and Down (a controller's LB and RB, also `[` `]`) change tab |171171 | A | Settings' Reception tab: what the tuners measured, by RF channel (the ones the scan found first), with the transmitters licensed on each. Up and Down pick one, Enter watches its first channel; above them the tuner, Measure now, which reads every RF channel on a free tuner and shows its progress, and Scan for channels (press twice) |
@@ -289,7 +289,7 @@ come, on now or not yet processed, are left out.
289289 each was watched (and any older ones part watched), backed up like the recordings:290290 291291 - every 6 hours it lists the show's newest 40 videos with yt-dlp, and every 30 minutes reads292- its channel's feed for new uploads in between;292+ its channel's feed for new uploads in between; Refresh, in the app, lists every show now;293293 - a video new to a show is looked up (its length, date, description and picture), a dozen294294 an update, a few seconds apart, so YouTube isn't asked too much;295295 - the newest unwatched episode of each show has where it streams from found ahead, so it
@@ -299,9 +299,14 @@ Nothing is downloaded: an episode streams from YouTube while it's watched, H.264
299299 1080p with English sound, its picture copied rather than transcoded, and played like a300300 recording, from where it was left. One show failing to list says so on that show alone.301301 302+Listing a show takes a few seconds. Adding one, or Refresh, does it at once; the background303+listings and lookups take turns with the YouTube channels' jobs, one yt-dlp job at a time,304+since listing a channel's whole catalog for its schedule can take minutes.305+302306 | | |303307 | --- | --- |304-| `GET /api/podcasts` | `{"shows": [{"id", "title", "url", "thumbnail", "minMinutes", "error", "episodes": [{"id", "showId", "title", "description", "published", "duration", "thumbnail", "position", "watched"}]}], "updating"}`, episodes newest first |308+| `GET /api/podcasts` | `{"shows": [{"id", "title", "url", "thumbnail", "minMinutes", "error", "episodes": [{"id", "showId", "title", "description", "published", "duration", "thumbnail", "position", "watched"}]}], "updating", "listed"}`, episodes newest first; `listed` is since when every show has been listed |309+| `POST /api/podcasts/refresh` | Lists every show now, a few at once, and replies as `GET /api/podcasts`; new episodes are looked up after |305310 | `GET /api/podcasts/search?q=` | YouTube channels: `[{"id", "title", "url", "thumbnail", "description", "subscribers"}]` |306311 | `POST /api/podcasts/shows` | `{"url", "minMinutes"}`: adds a show, listed before the reply, which is the show; 400 for what isn't one |307312 | `DELETE /api/podcasts/shows/{id}` | Takes a show out |
M app.go +9 -0
M frontend/dist/index.html +7 -1
M frontend/dist/js/podcasts.js +92 -12
@@ -8,9 +8,10 @@
88 // show's newest), Continue (episodes part watched) and each show; beside it99 // the episode picked, above a grid of the list's episodes. Its parts take1010 // the focus in turn (data-zone): the grid, the rail's menu (Left from the11-// grid's left edge), and the episode's actions (OK): Play or Resume, From12-// the start, Watched. Back goes from the actions to the grid.13-const podUI = { zone: 'grid', act: '', list: 'latest', sel: {}, at: 0, timer: 0, searching: false };11+// grid's left edge) and Refresh under it, and the episode's actions (OK):12+// Play or Resume, From the start, Watched. Back goes from the actions to13+// the grid.14+const podUI = { zone: 'grid', act: '', list: 'latest', sel: {}, at: 0, timer: 0, searching: false, refreshing: false };1415 1516 // asEpisode marks an episode for the player (state.recording), which plays1617 // recordings too.
@@ -66,6 +67,7 @@ function podZone(zone, act = '') {
6667 if (state.dock >= 0 || state.view !== 'podcasts') return;6768 if (zone === 'grid') return blurIn($('#podcasts'));6869 if (zone === 'menu') return void focusEl($(`#podcasts [data-list="${CSS.escape(podList()[0])}"]`));70+ if (zone === 'refresh') return void focusEl(f($('#podcasts'), 'refresh'));6971 const acts = $$('#podcasts .rd-actions .act.fx').filter(isShown);7072 if (!acts.length) return podZone('grid');7173 focusEl(acts.find((b) => b.dataset.act === podUI.act) || acts[0]);
@@ -132,12 +134,55 @@ function renderPodcasts() {
132134 </article>`;133135 }).join('') || (cur[0] !== 'latest' && cur[0] !== 'continue' && (shows.find((x) => x.id === cur[0]) || {}).error134136 ? `<p class="r-empty">${esc(shows.find((x) => x.id === cur[0]).error)}</p>` : '');137+ renderPodRefresh(v);135138 podRefocus();136139 }137140 141+// renderPodRefresh shows Refresh, under the lists when there are shows,142+// and when every show was last listed.143+function renderPodRefresh(v) {144+ const listed = state.podcasts && Date.parse(state.podcasts.listed);145+ $('.p-refresh', v).hidden = !podShows().length;146+ f(v, 'refresh').classList.toggle('busy', podUI.refreshing);147+ f(v, 'refresh').textContent = podUI.refreshing ? 'Refreshing' : 'Refresh';148+ f(v, 'rnote').textContent = podUI.refreshing ? 'Looking for new episodes'149+ : listed ? `Every show checked ${ago(listed)}` : '';150+}151+152+// refreshPodcasts has the server list every show again now, and says what153+// came of it. The new episodes are looked up after (their descriptions,154+// their dates to the minute), so they're loaded again in a bit.155+async function refreshPodcasts() {156+ if (podUI.refreshing || !podShows().length) return;157+ const had = new Set(podShows().flatMap((s) => (s.episodes || []).map((ep) => ep.id)));158+ podUI.refreshing = true;159+ renderPodcasts();160+ let st = null;161+ try {162+ st = await api().RefreshPodcasts();163+ } catch (e) {164+ toast(String(e && e.message ? e.message : e), 6000);165+ }166+ podUI.refreshing = false;167+ if (st) {168+ state.podcasts = st;169+ podUI.at = Date.now();170+ const fresh = podShows().flatMap((s) => s.episodes || []).filter((ep) => !had.has(ep.id)).length;171+ const failed = podShows().filter((s) => s.error);172+ const why = failed.length ? `. ${failed.length === 1 ? failed[0].title : `${failed.length} shows`} couldn't be checked` : '';173+ toast(`${fresh ? `${fresh} new episode${fresh === 1 ? '' : 's'}` : 'No new episodes'}${why}`, failed.length ? 5000 : 2500);174+ if (fresh) {175+ podUI.sel = {}; // the newest, where they are176+ clearTimeout(podUI.timer);177+ podUI.timer = setTimeout(loadPodcasts, 20_000);178+ }179+ }180+ if (state.view === 'podcasts') renderPodcasts();181+}182+138183 function podHint(ep) {139184 if (onRemote()) return 'OK for the actions, Left for the shows';140- return hints([['Enter', 'Enter', 'for the actions'], ...(resumable(ep) ? [['', 'Shift Enter', 'plays from the start']] : []), ['w', 'W', ep.watched ? 'unwatched' : 'watched']]);185+ return hints([['Enter', 'Enter', 'for the actions'], ...(resumable(ep) ? [['', 'Shift Enter', 'plays from the start']] : []), ['w', 'W', ep.watched ? 'unwatched' : 'watched'], ['r', 'R', 'refreshes']]);141186 }142187 143188 function wirePodcasts() {
@@ -148,6 +193,7 @@ function wirePodcasts() {
148193 podUI.list = b.dataset.list;149194 renderPodcasts();150195 });196+ f(v, 'refresh').addEventListener('click', refreshPodcasts);151197 v.addEventListener('click', (e) => {152198 const b = e.target.closest('[data-act]');153199 if (b) return podAct(b.dataset.act);
@@ -200,12 +246,14 @@ async function podAct(act) {
200246 // library.201247 function podcastsKey(e) {202248 const lists = podLists();249+ if (e.key === 'r' || e.key === 'R') return void refreshPodcasts();203250 if (e.key === '[' || e.key === ']') {204251 const i = lists.findIndex(([k]) => k === podUI.list);205252 podUI.list = lists[(i + (e.key === ']' ? 1 : lists.length - 1)) % lists.length][0];206253 return renderPodcasts();207254 }208255 if (podUI.zone === 'menu') return podMenuKey(e, lists);256+ if (podUI.zone === 'refresh') return podRefreshKey(e);209257 if (podUI.zone === 'actions' && podActionsKey(e)) return undefined;210258 const eps = podList()[2];211259 const sel = podUI.sel;
@@ -234,15 +282,18 @@ function podcastsKey(e) {
234282 return undefined;235283 }236284 237-// podMenuKey: Up and Down pick a list, Right or OK goes into it, Left (or238-// Up from the first) to the dock.285+// podMenuKey: Up and Down pick a list (Down from the last to Refresh),286+// Right or OK goes into it, Left (or Up from the first) to the dock.239287 function podMenuKey(e, lists) {240288 const i = lists.findIndex(([k]) => k === podUI.list);241289 switch (e.key) {242290 case 'ArrowUp': case 'ArrowDown': {243291 const j = i + (e.key === 'ArrowUp' ? -1 : 1);244292 if (j < 0) return openDock();245- if (j >= lists.length) break;293+ if (j >= lists.length) {294+ if (isShown(f($('#podcasts'), 'refresh'))) podZone('refresh');295+ break;296+ }246297 podUI.list = lists[j][0];247298 renderPodcasts();248299 podZone('menu');
@@ -258,6 +309,21 @@ function podMenuKey(e, lists) {
258309 return undefined;259310 }260311 312+// podRefreshKey: OK refreshes, Up goes back to the lists, Right into the313+// list picked, Left to the dock.314+function podRefreshKey(e) {315+ switch (e.key) {316+ case 'Enter': f($('#podcasts'), 'refresh').click(); break;317+ case 'ArrowUp': podZone('menu'); break;318+ case 'ArrowRight': if (podList()[2].length) podZone('grid'); break;319+ case 'ArrowLeft': openDock(); break;320+ case 'ArrowDown': break;321+ default: return undefined;322+ }323+ e.preventDefault();324+ return undefined;325+}326+261327 // podActionsKey: Left and Right along the actions, OK presses, Down (or262328 // Back) to the grid, Up to the dock.263329 function podActionsKey(e) {
@@ -359,27 +425,41 @@ async function searchShows(q) {
359425 openSettingsList('podfound');360426 }361427 428+// subscribers is a channel's following as YouTube says it: "3.39M429+// subscribers".430+const subscribers = (n) => `${new Intl.NumberFormat([], { notation: 'compact', maximumSignificantDigits: 3 }).format(n)} subscriber${n === 1 ? '' : 's'}`;431+362432 function podFoundRows(add) {363433 const found = state.podFound || [];364434 for (const c of found) {365- const have = podShows().some((s) => s.url === c.url);435+ // A channel found is its videos as a show.436+ const have = podShows().some((s) => s.url === c.url || s.url === `${c.url}/videos`);366437 add({367438 id: c.url, kind: have ? 'info' : 'action', verb: 'adds it', label: c.title,368- sub: [c.subscribers, c.description].filter(Boolean).join('. ').slice(0, 140),439+ sub: [c.subscribers ? subscribers(c.subscribers) : '', c.description].filter(Boolean).join('. ').slice(0, 140),369440 value: have ? 'Added' : 'Add', run: () => addShow(c),370441 });371442 }372443 if (!found.length) add({ id: 'none', kind: 'info', label: 'Nothing found', sub: 'Try other words, or paste the show\'s address' });373444 }374445 446+// addShow adds a show found, one at a time: it's listed first, a few447+// seconds.448+let podAdding = '';375449 async function addShow(c) {376- toast(`Adding ${c.title}...`, 30000);450+ if (podAdding) return toast(`Still adding ${podAdding}...`, 2000);451+ podAdding = c.title;452+ toast(`Adding ${c.title}...`, 120_000);453+ let show;377454 try {378- await api().AddPodcast(c.url, 0);455+ show = await api().AddPodcast(c.url, 0);379456 } catch (e) {380457 return toast(String(e && e.message ? e.message : e), 6000);458+ } finally {459+ podAdding = '';381460 }382- toast(`Added ${c.title}`, 2000);461+ const n = (show.episodes || []).length;462+ toast(`Added ${show.title || c.title}, ${n} episode${n === 1 ? '' : 's'}`, 2500);383463 closeSettingsList();384464 await loadPodcasts();385465 return undefined;
M frontend/dist/style.css +6 -0
M frontend/dist/web.js +1 -0
M frontend/wailsjs/go/main/App.d.ts +2 -0
M frontend/wailsjs/go/main/App.js +4 -0
M frontend/wailsjs/go/models.ts +2 -0
M internal/api/api.go +4 -0
M internal/api/api_test.go +9 -0
M internal/api/client.go +6 -0
M internal/podcast/podcast.go +13 -8
@@ -101,11 +101,13 @@ type episode struct {
101101 Watched bool `json:"watched,omitempty"`102102 }103103 104-// State is the library for the app: its shows, as they were added, and105-// whether a show is being listed.104+// State is the library for the app: its shows, as they were added,105+// whether a show is being listed, and since when every show has been (the106+// longest ago of their listings; zero without shows).106107 type State struct {107- Shows []Show `json:"shows"`108- Updating bool `json:"updating"`108+ Shows []Show `json:"shows"`109+ Updating bool `json:"updating"`110+ Listed time.Time `json:"listed,omitzero"`109111 }110112 111113 // Show is a show for the app.
@@ -191,8 +193,11 @@ func (l *Library) State() State {
191193 defer l.mu.Unlock()192194 l.load()193195 out := State{Shows: make([]Show, 0, len(l.st.Shows)), Updating: l.updating}194- for _, s := range l.st.Shows {196+ for i, s := range l.st.Shows {195197 out.Shows = append(out.Shows, s.view())198+ if i == 0 || s.Listed.Before(out.Listed) {199+ out.Listed = s.Listed200+ }196201 }197202 return out198203 }
@@ -232,9 +237,9 @@ func (l *Library) Search(ctx context.Context, query string) ([]Found, error) {
232237 }233238 234239 // Add adds the show at raw (a channel, one of its tabs, or a playlist; see235-// source), listing it first, with minMinutes the shortest video that's an236-// episode (DefaultMinMinutes when 0). A show already there is returned237-// as it is.240+// source), listing it first, at once (see list), with minMinutes the241+// shortest video that's an episode (DefaultMinMinutes when 0). A show242+// already there is returned as it is.238243 func (l *Library) Add(ctx context.Context, raw string, minMinutes float64) (Show, error) {239244 src, err := source(raw)240245 if err != nil {
M internal/podcast/podcast_test.go +50 -2
@@ -29,8 +29,9 @@ func TestMain(m *testing.M) {
2929 3030 // fakeYtDlp answers yt-dlp's command line, noting each run's arguments in3131 // the file AIRWAVES_FAKE_YTDLP names. Show A (@showa) has three episodes,32-// a clip and a premiere to come in its videos, and a past and a current33-// stream in its streams; Show B is a playlist with a private video. Any32+// a clip and a premiere to come in its videos (and a fourth episode, new,33+// with AIRWAVES_FAKE_NEW set), and a past and a current stream in its34+// streams; Show B is a playlist with a private video. Any3435 // video's details give it an hour, but "feedclip" lasts 200 seconds; with3536 // a format asked for, an H.264 picture and AAC sound six hours long.3637 func fakeYtDlp(args []string) int {
@@ -57,6 +58,10 @@ func fakeYtDlp(args []string) int {
5758 {"id": "up1", "title": "Premiere", "live_status": "is_upcoming"},5859 {"id": "ep1", "title": "Episode 1", "duration": 4000, "timestamp": now - 3*day},5960 }}61+ if os.Getenv("AIRWAVES_FAKE_NEW") != "" {62+ out.(map[string]any)["entries"] = append([]map[string]any{{"id": "ep4", "title": "Episode 4", "duration": 3600, "timestamp": now}},63+ out.(map[string]any)["entries"].([]map[string]any)...)64+ }6065 case u == "https://www.youtube.com/@showa/streams":6166 out = map[string]any{"channel": "Show A", "channel_id": "UCa", "entries": []map[string]any{6267 {"id": "now1", "title": "Live now", "live_status": "is_live"},
@@ -252,6 +257,49 @@ func TestUpdate(t *testing.T) {
252257 }253258 }254259 260+// Refresh lists every show again at once, a new upload joining, and261+// neither it nor Add waits behind yt-dlp's other listing jobs, which can262+// take minutes.263+func TestRefresh(t *testing.T) {264+ l, _ := library(t)265+ if _, err := l.Add(context.Background(), "@showa", 0); err != nil {266+ t.Fatal(err)267+ }268+ if _, err := l.Add(context.Background(), "https://www.youtube.com/playlist?list=PLtest00000001", 0); err != nil {269+ t.Fatal(err)270+ }271+ before := l.State()272+ if before.Listed.IsZero() {273+ t.Fatal("never listed")274+ }275+ t.Setenv("AIRWAVES_FAKE_NEW", "1")276+ l.YtDlp.Jobs.Lock() // a channel's whole catalog, being listed277+ defer l.YtDlp.Jobs.Unlock()278+ done := make(chan State)279+ go func() {280+ st := l.Refresh(context.Background())281+ if _, err := l.Add(context.Background(), "https://www.youtube.com/playlist?list=PLtest00000002", 0); err != nil {282+ t.Error(err)283+ }284+ done <- st285+ }()286+ var st State287+ select {288+ case st = <-done:289+ case <-time.After(30 * time.Second):290+ t.Fatal("waited behind the listing job")291+ }292+ if got := ids(st.Shows[0].Episodes); !slices.Equal(got, []string{"ep4", "ep3", "ep2", "ep1"}) {293+ t.Errorf("episodes: %v", got)294+ }295+ if !st.Listed.After(before.Listed) || st.Updating {296+ t.Errorf("listed %v, was %v; updating %v", st.Listed, before.Listed, st.Updating)297+ }298+ if n := len(l.State().Shows); n != 3 {299+ t.Errorf("%d shows", n)300+ }301+}302+255303 func TestProgressPersists(t *testing.T) {256304 l, _ := library(t)257305 if _, err := l.Add(context.Background(), "@showa", 0); err != nil {
M internal/podcast/update.go +43 -11
@@ -13,6 +13,8 @@ import (
1313 "strings"1414 "time"1515 16+ "golang.org/x/sync/errgroup"17+1618 "airwaves/internal/web"1719 "airwaves/internal/youtube"1820 )
@@ -92,11 +94,14 @@ func (l *Library) update(ctx context.Context) {
9294 }9395 l.updating = len(lists) > 09496 l.mu.Unlock()97+ tool := l.tool()9598 for _, s := range lists {9699 if ctx.Err() != nil {97100 return98101 }102+ tool.Jobs.Lock() // one listing job at a time across yt-dlp's users99103 l.relist(ctx, s)104+ tool.Jobs.Unlock()100105 }101106 l.mu.Lock()102107 l.updating = false
@@ -119,16 +124,16 @@ func (l *Library) listDue(s *show, now time.Time) bool {
119124 return now.Sub(s.Listed) >= listEvery120125 }121126 122-// list lists a show's newest videos, one listing job at a time across123-// yt-dlp's users.127+// list lists a show's newest videos, a few seconds' work. It doesn't take128+// its turn with yt-dlp's other listing jobs, which can be a channel's whole129+// catalog and take minutes: the background updates take turns (see130+// update), while someone adding a show or refreshing doesn't wait behind131+// them.124132 func (l *Library) list(ctx context.Context, s src) (youtube.Listing, error) {125133 ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)126134 defer cancel()127- tool := l.tool()128135 args := append(slices.Clone(youtube.ListingArgs), "-I", "1:"+strconv.Itoa(listLimit), s.url)129- tool.Jobs.Lock()130- raw, err := tool.Run(ctx, args...)131- tool.Jobs.Unlock()136+ raw, err := l.tool().Run(ctx, args...)132137 if err != nil {133138 return youtube.Listing{}, err134139 }
@@ -138,9 +143,39 @@ func (l *Library) list(ctx context.Context, s src) (youtube.Listing, error) {
138143 return youtube.ParseListing(raw)139144 }140145 146+// refreshAtOnce is how many shows Refresh lists at the same time.147+const refreshAtOnce = 4148+149+// Refresh lists every show again now, rather than at its turn: for someone150+// at the screen, so a few at once, and not behind yt-dlp's other jobs (see151+// list). The new episodes are looked up after, in the background. It152+// returns the library as refreshed.153+func (l *Library) Refresh(ctx context.Context) State {154+ l.mu.Lock()155+ l.load()156+ shows := slices.Clone(l.st.Shows)157+ l.mu.Unlock()158+ var g errgroup.Group159+ g.SetLimit(refreshAtOnce)160+ for _, s := range shows {161+ g.Go(func() error {162+ l.relist(ctx, s)163+ return nil164+ })165+ }166+ g.Wait()167+ l.mu.Lock()168+ l.poke()169+ l.mu.Unlock()170+ return l.State()171+}172+141173 // relist lists a show again and takes in what's new.142174 func (l *Library) relist(ctx context.Context, s *show) {143175 lst, err := l.list(ctx, s.source())176+ if err != nil && ctx.Err() != nil {177+ return // stopped, not failed: it's listed at its turn178+ }144179 now := l.clock()145180 l.mu.Lock()146181 defer l.mu.Unlock()
@@ -149,9 +184,6 @@ func (l *Library) relist(ctx context.Context, s *show) {
149184 }150185 s.Listed = now151186 if err != nil {152- if ctx.Err() != nil {153- return154- }155187 s.Error = err.Error()156188 log.Printf("podcasts: %s: %v", s.Title, err)157189 l.save()
@@ -383,10 +415,10 @@ func (l *Library) lookups(ctx context.Context) {
383415 // lookup looks up a video's details, and drops it from its show when it384416 // isn't an episode after all.385417 func (l *Library) lookup(ctx context.Context, s *show, id string) {418+ tool := l.tool()419+ tool.Jobs.Lock() // its turn first: its time starts when it runs386420 ctx, cancel := context.WithTimeout(ctx, resolveTime)387421 defer cancel()388- tool := l.tool()389- tool.Jobs.Lock()390422 raw, err := tool.Run(ctx, "-J", "--skip-download", "--no-playlist", "--ignore-no-formats-error", youtube.WatchURL(id))391423 tool.Jobs.Unlock()392424 var info youtube.Info
M internal/service/podcasts.go +10 -0
M internal/youtube/listing.go +3 -3