Repository navigation
1.5.18: Remember a track with no text and read the video id from the cached track list - #70
Merged
Merged
Conversation
A track that brought no text was not cached, so the same call again
spent one more caption request on it. The server now keeps an entry
sub:{url}:{type}:{lang}:{format}:empty for the metadata TTL, in both
flows, with and without lang. The canary does not read or write it.
Off YouTube, a call by name after a list answer ran the metadata
request again only for the video id. videoIdFor now reads the id from
the cached track list first.
Issue #60. ADR 006, CHANGELOG and .env.example follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mark lives for the metadata TTL from the empty answer, so it can outlive a cached track list. Within that time a repeated call sends no caption request, but it can still run the metadata request. The docs and comments now say "no caption request" and "the same time as a track list", and they name a network error as one temporary failure that the mark can hold. Wording only; no code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
downloadSubtitles now returns '' for a run that went through with no text and null for a failed run. Only '' writes the no-text entry, so a network error or an HTTP 5xx on the track does not answer "no text" to every caller for an hour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the entries of this PR under 1.5.18 and bump the version in package.json, package-lock.json and server.json. The tests of the no-text mark keep the smaller beforeEach from the test cleanup: the top-level reset already clears the cache mocks and turns speech-to-text off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
samson-art
marked this pull request as ready for review
September 30, 2026 12:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #60
What and why
The review of #55 found two extra requests on the caption path. Caption requests use a quota per outbound IP, and every extra request can make a rate limit last longer (ADR 002, ADR 003).
downloadSubtitlesreturned null, the caller got the list answer, and the server stored nothing. The list answer is aNotFoundErrorwith the track lists and one next step. The same call again asked for the same track again. This was true for auto-discovery (a call withoutlang, ADR 006) and for a request by name (typeandlang).handleExplicitRequestFlowdownloaded the track and then calledloadVideoJsononly to get the id. That is one more metadata run (one yt-dlp run that reads the video info and the track list). The list answer cached that track list, with the id, one call before.Three test cases showed both faults before the fix (see Verified).
The fix is in
src/validation.ts:downloadTrack, is now the only path todownloadSubtitles. Both flows call it. Before a request, it reads the mark. The mark is a cache entrysub:{url}:{type}:{lang}:{format}:emptyfor a track that brought no text. If the mark is there,downloadTrackreturns null and makes no request. If a request brings no text, it writes the mark with the metadata TTL (CACHE_TTL_METADATA_SECONDS, 1 hour by default). The canary passesskipCache, so it neither reads nor writes the mark.readCachedAvail, holds the cache read ofloadAvailableSubtitles, with the same hit and miss counts.videoIdForcalls it beforeloadVideoJson. For the canary (skipCache),videoIdFordoes not change.The mark and the list read work only with
CACHE_MODE=redis. With the cache off (the default),getreturns nothing andsetstores nothing, so both paths behave as before. The tool contract, the caller texts, the metric names and labels, and the per-call log line do not change. This PR adds no request and no retry.Commits: f8036a1 (the fix, the tests and the docs), 7fdb9d3 (review 1, text only).
Decisions
The maintainer took these decisions on 2026-09-26, in the brief:
videoIdForreads the cached track list before it starts a metadata run.fix/1.5.18-caption-leftovers. The entries go under## [Unreleased].Acceptance criteria
src/validation.test.ts:remembers a track that brought no text, so the same call again asks for nothing, in two cases,without langandby name. An in-memory map backs the mocked cachegetandset. Two identical calls give the sameNotFoundErrortext, anddownloadSubtitlesruns once. The mark is stored with TTL 3600, the metadata TTL in the mock.src/validation.test.ts:names a track off YouTube after a list answer without another metadata run. A Vimeo URL with two official tracks and no reported language gives the list answer. Then a call withtype: 'official'andlang: 'de'returns the track with thevideoIdfrom the cached list.fetchYtDlpJsonruns once in total.skips the cache when asked, so the canary always exercises yt-dlpnow also gives an empty probe. It expects no:emptykey in the calls togetandset. The TTL is the metadata TTL, no env var is added, and the CHANGELOG names the key shape.Plan
Files that change
src/validation.ts: the new helpersdownloadTrackandreadCachedAvail. Auto-discovery (downloadWithAutoDiscover) andhandleExplicitRequestFlowcalldownloadTrack.loadAvailableSubtitlescallsreadCachedAvail.videoIdForreads the cached list beforeloadVideoJson.src/validation.test.ts: two new tests (one of them in two cases), a small in-memory cache helper, and the extended canary test.docs/adr/006-original-language-without-lang.md: "The track" describes the mark. Consequences says that the second call off YouTube makes no metadata run. A new "Don't" line says not to keep the mark longer than a track list is kept. Sources names issue Two extra caption-path requests remain after the original-language change #60.CHANGELOG.md: two entries under[Unreleased], Fixed. The first names the key shape. The second says that the list read counts incache_hits_total{kind="avail"}orcache_misses_total{kind="avail"}..env.example: the comment forCACHE_TTL_METADATA_SECONDSnames the mark.No env var is added, changed or removed, so the README env table does not change.
Order of work
red-60.txt): 3 failed and 1 passed. The canary test passed, because nothing read or wrote the mark yet.downloadTrackandreadCachedAvail, and changed both flows andvideoIdFor. Updated ADR 006, the CHANGELOG and.env.example. Commit f8036a1.get_video_infoor a widget cached it first. So the mark can outlive the list by up to one TTL. In that time, a repeated auto-discovery call reads the mark but makes a new metadata run. The docs said "no request" and "as long as the track list". They now say "no caption request" and "the same time as a track list".downloadSubtitles. That includes the catch path for unclassified errors, for example an HTTP 5xx on the subtitle URL, a connection reset or a proxy error. The docs said "a failure about the video". They now name network errors too.downloadSubtitlesnow returns''for a run that went through with no text, andnullfor a failed run. It has one caller,downloadTrack, and only''writes the mark. A network error or an HTTP 5xx on the track is not remembered, so the next call asks again, as before this change. Every other reader of the result checks it with!content, so''andnullact the same there.Risks
CACHE_TTL_METADATA_SECONDS(1 hour by default) to every caller that shares the Redis. The maintainer chose the metadata TTL. A failed run is not remembered (eef41ae).rate_limited.assertSubtitlesNotRateLimitedruns before the mark is read, because speech-to-text can follow an empty track. Auto-discovery with a cached list and a mark answers with the list and makes no run. This matches ADR 006: the server tests the hold only in front of a run.availlookup:cache_hits_total{kind="avail"}orcache_misses_total{kind="avail"}grows by one. The names and labels do not change. The CHANGELOG says so.Verified
Red before the fix (
red-60.txt, on 152a609 with the new tests):Green after the fix, on f8036a1:
npx jest src/validation.test.ts -w 2: 121 of 121. It ran twice, before and after a lint fix to the test.npx prettier --write,npx eslint --fixandnpx tsc --noEmit -p .on the changed files: no problems.On 7fdb9d3:
npx jest src/validation.test.ts -w 2 -t 'remembers a track that brought no text|names a track off YouTube after a list answer|skips the cache when asked': 4 passed. prettier and eslint made no changes.make check-no-smoke(format-check, lint, typecheck, full Jest, build) passed in the pre-commit hook on f8036a1 and on 7fdb9d3. The test count of the gate was not kept. The gate also runssrc/mcp-core.test.tsandsrc/canary.test.ts, which mockvalidation.js.Mutation drill on f8036a1: 6 of 6 mutations fail a named test. M3 ran in two parts, and both parts failed. After each mutation,
npx jest src/validation.test.ts -w 2ran, and the source was restored.:emptyentry indownloadTrack.remembers a track that brought no text, so the same call again asks for nothing(without lang) and (by name).ttlSubtitlesSecondsinstead ofttlMetadataSeconds.remembers a track that brought no text, so the same call again asks for nothing(without lang) and (by name).!skipCacheguard on the read, so the canary reads the entry.skips the cache when asked, so the canary always exercises yt-dlp.!skipCacheguard on the write, so the canary writes the entry.skips the cache when asked, so the canary always exercises yt-dlp.videoIdForskipsreadCachedAvailand callsloadVideoJsondirectly.names a track off YouTube after a list answer without another metadata run.downloadSubtitlesdirectly instead ofdownloadTrack.remembers a track that brought no text, so the same call again asks for nothing(by name).downloadSubtitlesdirectly instead ofdownloadTrack.remembers a track that brought no text, so the same call again asks for nothing(without lang).7fdb9d3 changes only comments and docs, so it has no mutation of its own.
eef41ae:
npx jest src/validation.test.ts src/youtube.test.ts -w 2: 248 of 248. Mutation drill, 2 of 2:downloadTrackwrites the mark on any falsy result (!content), not only on''.does not remember a failed track run, so the next call asks again.nullagain, not''.should return '' when the run brings no subtitle file,should return '' when the subtitle file is empty,keeps the limit when yt-dlp answers without a track.The pre-commit gate passed on eef41ae.
Not verified
make check, smoke, e2e and load tests: not run. They call real YouTube from this IP, and the brief forbids them.getandset. They assert the key shape and the TTL on the mock calls.Not in scope
After merge
After the deploy, on the server that serves real calls:
CACHE_MODE=redis. If it does not, the mark and the list read do nothing there. The only change is one morecache_misses_total{kind="avail"}for each request by name off YouTube.sub:*:empty(redis-cli --scan --pattern 'sub:*:empty').redis-cli TTL <key>must be at mostCACHE_TTL_METADATA_SECONDS(3600 by default), not near the subtitles TTL (604800 by default).subtitle_requests_totaldoes not grow, and the server log has no new lineDownloading <type> subtitles in language <lang>for it.cache_hits_total{kind="avail"}grows by one for that call, and the call returns the track with a real video id, notunknown.transcriptor_canary_okstays 1, and that Redis has no:emptykey for the canary URL. The canary URL isCANARY_URL, or the default insrc/canary.ts.Error downloading subtitles. Such a failed run writes no mark (eef41ae), so the same call again asks for the track again.🤖 Generated with Claude Code