WorkWingman — Study Videos & Podcasts (Technical)¶
Study & prep surfaces two more resource types matched to a job's JD keywords/role: real YouTube study videos (with an opt-in path to create a playlist in the user's own YouTube account) and podcast suggestions (a curated baseline plus free iTunes Search API matches). Both follow the app's standing conventions: config-gate for keyed APIs, BYO for the user's own account, anti-hallucination (only real API results, never invented), and explicit confirm before creating anything in the user's account.
Topic matching (shared by videos + podcasts)¶
Both services derive their search topics through StudyMediaTopics.For(job)
(src/WorkWingman.Infrastructure/Services/StudyMediaTopics.cs),
which prefers Fit.MatchedKeywords/Fit.GapKeywords when present. In practice, real jobs pulled
by LinkedInJobsScraper today only populate Fit.Summary — the keyword lists stay empty for
the normal saved-job flow (the same gap StudyPlanService's existing YouTube/Udemy resources
have). Without a fallback, search would silently never run for any real scraped job. So when both
keyword lists are empty, StudyMediaTopics falls back to significant words parsed from the job's
Title (stripped of generic seniority/role stop-words like "Senior," "Engineer," "II") — the role
itself is still a meaningful "tech/role" match. See StudyMediaTopicsTests.cs and
YouTubeStudyServiceTests.SearchForJob_RealScrapedJobShape_WithNoFitKeywords_StillSearchesByTitle.
YouTube study-video search¶
API: YouTube Data API v3 — search.list.
Auth: an API key (WorkWingman:YouTubeApiKey), not OAuth — search is a read-only, keyed
call, separate from playlist creation below. Config-gated the same way every other optional key
is in this codebase: absent config means "not configured," and the service returns an honestly
empty result rather than a fake one.
- File:
src/WorkWingman.Infrastructure/Clients/YouTubeConfig.cs—IYouTubeApiKeyProvider/ConfiguredYouTubeApiKeyProviderreadsbuilder.Configuration["WorkWingman:YouTubeApiKey"]. Empty by default inappsettings.json. - File:
src/WorkWingman.Infrastructure/Clients/YouTubeClient.cs—YouTubeSearchClient.SearchAsync(query, maxResults)callsGET https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&...&key=....
Quota verdict (verified against Google's current docs, 2026):
| Call | Quota cost | Notes |
|---|---|---|
search.list |
100 units/call (billed against a dedicated ~100-calls/day search bucket since the 2026 granular-quota change) | New projects get a default of 10,000 units/day for all other endpoints combined, plus this dedicated search allowance — in practice, budget for roughly 100 searches/day. |
playlists.insert |
50 units/call | Creates the playlist container. |
playlistItems.insert |
50 units/call | One call per video added. |
Because search.list is the most quota-expensive read call, YouTubeStudyService caches
results per job (and separately for "all my saved jobs" overall mode) in
LocalJsonStore under youtube-study-video-cache, so repeat page views never re-spend quota.
See YouTubeStudyService.SearchAndCacheAsync.
Two cache-correctness details worth calling out:
- No key configured yet → never cached. If the API key isn't configured, the (empty) result is returned but deliberately not persisted to the cache — otherwise a job opened before the key was set up would be permanently stuck showing "no videos," even after the key is added later, until someone manually cleared the cache file.
- Overall-mode cache key tracks the actual topic set, not a fixed constant. The "all my saved
jobs" cache entry is keyed by a short SHA-256 fingerprint of the current sorted, deduped topic
list (
TopicsFingerprint). A LinkedIn resync or a newly saved job that changes the combined topic set gets a fresh cache entry automatically; stale fingerprints from a queue that has since changed are pruned opportunistically on the next overall search rather than accumulating forever.
Deduplication: the same video can legitimately match more than one searched topic (e.g. a
"Kubernetes + Terraform" video matching both keywords); SearchAndCacheAsync tracks VideoIds
already added and skips repeats, so the response never contains a duplicate id. This matters
because the frontend tracks video cards and selection state by VideoId alone — a duplicate id
would make unrelated cards toggle together.
Anti-hallucination: every StudyVideo returned is built strictly from a parsed
YouTubeSearchClient.SearchListResponse item — video id, title, channel, and thumbnail all come
from the API response. An item missing a videoId is dropped, never patched with an invented id
(YouTubeStudyServiceTests.SearchForJob_ItemsMissingVideoId_AreSkipped_AntiHallucinationGuard).
When no API key is configured, the result is an empty list, not a fabricated one
(Search_WhenApiKeyNotConfigured_ReturnsEmptyResults_NoHttpCall).
Per-job vs. overall: SearchStudyVideosForJobAsync(jobId) searches on that job's
Fit.MatchedKeywords + Fit.GapKeywords (same topic-extraction pattern as StudyPlanService).
SearchStudyVideosOverallAsync() aggregates distinct topics across every job in
IJobQueueService.GetQueueAsync() — the "all my saved jobs" mode.
YouTube playlist creation — user's own account, opt-in¶
API:
playlists.insert +
playlistItems.insert.
Auth: the caller's own Google OAuth access token with the
https://www.googleapis.com/auth/youtube scope (GoogleScopes.YouTube in
GoogleTokenProvider.cs),
resolved through the existing IGoogleTokenProvider — the same seam GoogleSheetsClient and
GoogleCalendarClient use. This means the playlist is created in the user's own YouTube
account, never the app's. Until the desktop OAuth flow is wired up,
UnconfiguredGoogleTokenProvider throws a clear "connect Google on the Connections screen"
message; YouTubeStudyService.CreatePlaylistAsync catches that and turns it into a no-op result
rather than an unhandled exception.
File: src/WorkWingman.Infrastructure/Clients/YouTubeClient.cs —
YouTubePlaylistClient.CreatePlaylistAsync / AddPlaylistItemAsync, both decorated with
WithOAuthBearerToken(tokens.GetAccessToken(GoogleScopes.YouTube)).
Two-gate guardrail — creating a playlist is state-changing (it writes real content to the user's account), so it requires both:
- Explicit opt-in confirm —
IYouTubeStudyService.CreatePlaylistAsync(title, videoIds, confirmed, ct)takes aconfirmedflag; whenfalse, it is a pure no-op and makes no HTTP call at all (CreatePlaylist_WithoutConfirmation_IsNoOp_NoHttpCall). The frontend only setsconfirmed: trueafter the user clicks through a two-step confirm box describing exactly what will happen (see Frontend below). - Cross-origin trust gate —
POST /api/study-media/playlistre-checksrequest.Confirmedserver-side (never trusts the client alone) and requiresApiToken.IsTrusted(Request)(the same per-launch token gate documented in security.md) before calling the service. A hostile web page reaching the loopback API cannot forge the token, so it cannot create a playlist in the user's account even if it POSTs directly. - File:
src/WorkWingman.Api/Controllers/StudyMediaController.cs.
Two distinct "no Google/YouTube access" cases, two distinct messages. The playlists.insert
call can fail in two different ways, both surfaced as a clean no-op (never an unhandled 500) but
with different, accurate messages:
- Google isn't connected at all —
UnconfiguredGoogleTokenProviderthrowsInvalidOperationExceptionbefore any HTTP call happens. Message: "Connect Google and grant YouTube access first." - Google IS connected, but YouTube itself definitively rejects the call — e.g. an existing
Google connection made before the
youtubescope was added to this app, so the token lacks that scope, or a 401/403/quota error.playlists.insertthrowsFlurlHttpExceptionwith an actual HTTP response, so Google's servers definitively did not create the playlist. Message: "Couldn't create the playlist in your YouTube account — reconnect Google and grant YouTube access... " — deliberately not the same "Connect Google" wording, since claiming Google isn't connected when it demonstrably is would be misleading. SeeCreatePlaylist_GoogleConnected_ButYouTubeScopeRejected_IsNoOp_NotUnhandled500. - The
playlists.insertcall fails ambiguously — anything that isn't a clean 4xx client rejection:Polly.Timeout.TimeoutRejectedException(a timeout), aFlurlHttpExceptionwithStatusCode == null(a response-less transport failure — connection reset, DNS failure, etc.), or aFlurlHttpExceptionwith a 5xx status. A 5xx in particular can mean YouTube accepted the write and then failed to report success — the classic "request succeeded, response didn't" case. All three mean the request may have reached Google and created the playlist before the failure surfaced — genuinely different from a definite 4xx rejection (401/403/400), which means YouTube rejected the request outright. The definite-rejection branch is therefore scoped toex.StatusCode is >= 400 and < 500; everything else routes to the ambiguity-warning branch. It must NOT be reported as a clean no-op — that would invite a retry that creates a second, duplicate playlist for the same title. The message names the playlist title and explicitly tells the user to check their YouTube account before trying again. SeeCreatePlaylist_PlaylistInsertTimesOut_WarnsOfAmbiguity_NotBlindNoOp,CreatePlaylist_PlaylistInsertFailsWithNoResponse_WarnsOfAmbiguity_NotDefiniteRejection, andCreatePlaylist_PlaylistInsertReturns5xx_IsTreatedAsAmbiguous_NotDefiniteRejection.
Quota-aware batching: one playlists.insert call, then one playlistItems.insert call per
selected video — the service does not batch beyond what the API supports.
Partial-success handling. Once playlists.insert succeeds, the playlist genuinely exists in
the user's account — a later playlistItems.insert failure (a stale/unavailable video id, a
quota error, a transient timeout on one item) must never be reported as "nothing happened." That
would invite exactly the retry the non-idempotent-write pipeline above is guarding against: a
retry of CreatePlaylistAsync after a "failure" would call playlists.insert again and create a
second playlist, since the first one already exists. So CreatePlaylistAsync always returns
Created = true with the real PlaylistId/PlaylistUrl and however many videos actually made it
in (VideosAdded) once the playlist itself was created — the message explicitly says the playlist
already exists and asks the user to add the rest manually rather than retrying. Both of
playlistItems.insert's failure modes are caught here: FlurlHttpException (an HTTP error) and
Polly.Timeout.TimeoutRejectedException (the item-add call itself exceeding
NonIdempotentWrite's timeout) — the same two-exception-type pattern used in
PodcastSuggestionService's iTunes fallback.
See CreatePlaylist_PlaylistCreated_ButAnItemAddFails_ReportsPartialSuccess_NotFailure and
CreatePlaylist_PlaylistCreated_ButItemAddTimesOut_ReportsPartialSuccess_NotUnhandled500.
Non-retrying pipeline for these writes. Unlike every other Google REST client in this codebase
(GoogleSheetsClient, GoogleCalendarClient, etc.), which use ResiliencePipelines.Default (3
retries on 429/5xx), YouTubePlaylistClient uses a new ResiliencePipelines.NonIdempotentWrite
pipeline — timeout only, zero retries. playlists.insert/playlistItems.insert are
non-idempotent: if Google accepts the request but the response is lost or reported as a transient
error, retrying could create a second, duplicate playlist or duplicate item in the user's own
account. A failure surfaces to the caller instead of being silently retried.
See ResiliencePipelines.cs.
Podcast suggestions — curated baseline + free iTunes Search API¶
Default source: Apple iTunes Search API
(https://itunes.apple.com/search?media=podcast) — free, public, no API key. This is why it's
the default rather than a keyed alternative: it needs no config gate at all. Listen Notes and
Podcast Index (both keyed) would need the same config-gate treatment as
WorkWingman:YouTubeApiKey if added later; they are not implemented today.
- File:
src/WorkWingman.Infrastructure/Clients/ITunesPodcastClient.cs—SearchPodcastsAsync(term, limit)callsGET https://itunes.apple.com/search?media=podcast&entity=podcast&limit=...&term=....
Curated baseline: PodcastSuggestionService.GetCuratedPodcasts() returns a hand-picked list
of reputable tech/career/interview/leadership shows (Software Engineering Daily, The Changelog,
Command Line Heroes, Talk Python To Me, Syntax, Coding Blocks, Manager Tools, Coaching for
Leaders, The Trilogy, Developer Tea) — marked Curated = true so the frontend and tests can tell
these apart from live search matches. This list is always available, even offline or before any
job is selected.
JD-matched results: GetSuggestionsForJobAsync(jobId) / GetSuggestionsOverallAsync() layer
iTunes Search API results (Curated = false) on top of the curated baseline, one search call per
distinct JD topic (capped at 5 per request to bound the call count — iTunes's own documented
soft rate limit is roughly 20 calls/minute).
Anti-hallucination: every non-curated PodcastSuggestion is built strictly from a parsed
ITunesPodcastClient.ITunesSearchResponse item — name, url, and publisher all come from the API
response. An item missing a collectionName or collectionViewUrl is dropped
(GetSuggestionsForJob_ItemsMissingNameOrUrl_AreSkipped_AntiHallucinationGuard).
Deduplication: the same show can legitimately match more than one searched topic;
MatchAsync tracks Urls already added (seeded with the curated list's own URLs) and skips
repeats, so the response never contains a duplicate. This matters because the frontend renders
podcasts with @for (...; track p.url) — a duplicate URL would give Angular a duplicate tracking
key.
Graceful degradation when iTunes is unreachable. If a per-topic iTunes call fails or times out
(FlurlHttpException from an HTTP error, or Polly.Timeout.TimeoutRejectedException when the
shared resilience pipeline's timeout fires — two distinct failure modes, both caught), that topic
is skipped and the curated baseline already collected is still returned rather than the whole
endpoint throwing a 500. Same pattern as WarnNoticeSource/SecEdgarSource's adapter degradation.
BYO subscribe — never auto-subscribes. Every suggestion is a deep link
(CollectionViewUrl, an https://podcasts.apple.com/... page, or the curated show's own site).
The user opens it and subscribes in their own podcast app — WorkWingman never calls a
subscribe API, never creates an account anywhere, and never stores subscription state.
API — StudyMediaController (/api/study-media)¶
| Method | Path | Body | Returns | Notes |
|---|---|---|---|---|
| GET | /api/study-media/videos/{jobId} |
— | StudyVideoSearchResult |
Read-only. Cached per job. |
| GET | /api/study-media/videos/overall |
— | StudyVideoSearchResult |
Read-only. "All my saved jobs" mode. |
| POST | /api/study-media/playlist |
{ title, videoIds, confirmed } |
PlaylistCreateResult |
Token-gated (ApiToken.IsTrusted) + requires confirmed: true. Writes to the user's own YouTube account. |
| GET | /api/study-media/podcasts/curated |
— | PodcastSuggestion[] |
Read-only. Hand-picked baseline, no job context needed. |
| GET | /api/study-media/podcasts/{jobId} |
— | PodcastSuggestion[] |
Read-only. Curated + iTunes-matched. |
| GET | /api/study-media/podcasts/overall |
— | PodcastSuggestion[] |
Read-only. "All my saved jobs" mode. |
POST /playlist mirrors the dependency-doctor install gate exactly
(see api-reference.md):
an untrusted request gets 401; an unconfirmed request gets 400.
Frontend¶
frontend/src/app/features/study/study.ts / study.html add two panels to the existing Study &
prep page:
- Study videos — cards with a checkbox, thumbnail, title (links to the real YouTube watch
page), channel, and matched-keyword tag. Selecting videos enables "Create a YouTube playlist,"
which opens a confirm box stating plainly that this creates a real, private playlist in the
user's own YouTube account — nothing is sent to the API until the user clicks "Confirm & create
in my YouTube." A
PlaylistCreateResult.Messageis always shown afterward, success or no-op (e.g. "Connect Google and grant YouTube access first"). - Suggested podcasts — a simple list (curated entries badged
PICK, iTunes matches badgedMATCH), each a link to its subscribe page, with a standing footnote that subscribing happens in the user's own podcast app. - Per-job / overall toggle — the existing job tabs gain an "All my saved jobs" tab
(shown once more than one job is saved) that switches both panels to
.../overallendpoints. Switching to overall mode also clears the previously selected job'splanand hides the per-job "Generate a Claude study project" card and JD-matched resource grid (documentation/courses/coding) — both are inherently tied to one job, so leaving them visible (and actionable) while the media panels say "all your saved jobs" would let the user generate a Claude project for a stale, no-longer-displayed job. SeeselectOverall clears the previously selected job's plan (no stale per-job UI/actions)instudy.spec.ts. - Both panels are empty-safe — no videos/podcasts renders a plain footnote, never an error
state, matching the rest of the Study & prep page's
@emptypattern. - Stale-response guard. Switching jobs or toggling overall mode while a previous media request
is still in flight bumps an internal request token; a response is only committed to
studyVideos/podcastsif it's still the most recent request. Without this, a slow response for the job you left could arrive after a fast response for the job you switched to and silently overwrite it with the wrong job's media.
api-token.interceptor.ts was extended (it previously only covered
/api/dependencies/install) to also attach the per-launch X-WorkWingman-Token header to
/api/study-media/playlist requests — the same interceptor, one more gated path.
resilience.interceptor.ts's LONG_RUNNING_PATHS exemption list was extended to include
/api/study-media/playlist: creating a playlist is one playlists.insert call plus one
playlistItems.insert round-trip to Google per selected video, which can exceed the default 30s
client-side ceiling. Without the exemption, a slow-but-successful create could show as a false
timeout while the backend kept writing — and a user retry on that false failure could create a
second, duplicate playlist in their account.
Tests¶
YouTubeStudyServiceTests.cs— FlurlHttpTestfixtures (zero real network): search hits the exact endpoint with the API key and no OAuth header, malformed items are dropped, per-job/overall caching avoids a second HTTP call, an unconfigured key never caches (so search runs once a key is later added), the overall cache key tracks the actual topic set (a queue change produces a fresh result, not a stale one), a real-scraped-job shape (noFitkeywords, onlyFit.Summary) still searches via the title fallback, duplicateVideoIds across topics are deduplicated, playlist creation is a no-op without confirmation / without videos / without a Google connection, a confirmed+connected create posts toplayliststhenplaylistItemswith the user's bearer token, andCreatePlaylist_PlaylistCreated_ButAnItemAddFails_ReportsPartialSuccess_NotFailureproves a mid-batch item failure still reports the created playlist rather than a bare failure.RestClientTests.cs—YouTubePlaylistClient_CreatePlaylist_On503_DoesNotRetry_NonIdempotentWriteproves the write pipeline makes exactly one HTTP call on a transient 503, unlike every other Google client here.PodcastSuggestionServiceTests.cs— curated baseline always present, iTunes matches parsed verbatim, malformed items dropped, no API key/auth header sent (free API), overall-mode aggregation, and both an HTTP-error and a Polly-timeout failure degrade to the curated baseline rather than throwing.StudyMediaTopicsTests.cs— Fit keywords take priority when present; the title fallback (with stop-words filtered) fires for the exact shapeLinkedInJobsScraperproduces; an empty title with no keywords returns empty rather than throwing.ApiSmokeTests.cs—/videos/overalland/podcasts/curatedreturn200; the playlist endpoint returns401without the trust token,400when unconfirmed even from a trusted origin, and200with a no-op message when confirmed but Google isn't connected. Deliberately not in this smoke suite:/podcasts/{jobId}//podcasts/overall— they call the real, live iTunes Search API for whatever jobs are in the queue, and this in-memoryWebApplicationFactoryhost has no seam to stub that out, so exercising them here would make the smoke suite flaky/slow/offline-unfriendly. Their actual logic is fully covered with FlurlHttpTestfixtures inPodcastSuggestionServiceTests.csinstead. (/videos/overallIS safe to smoke-test because no YouTube API key is configured for the test host, so no HTTP call happens at all.)- Frontend:
study.spec.tscovers loading videos/podcasts, toggling selection, the two-step confirm/cancel/confirm flow, the no-op message path, overall-mode switching, empty-safe rendering, and that a stale/slow response from a since-abandoned job never overwrites the currently-selected job's media.resilience.interceptor.spec.tscovers the playlist-create timeout exemption. - Both new source globs (
**/Services/*.cs,**/Clients/*.cs) are already included instryker-config.json— no config change needed, since these files live in existing mutated directories.
New dependency¶
Microsoft.Extensions.Configuration.Abstractions was added to
WorkWingman.Infrastructure.csproj
so ConfiguredYouTubeApiKeyProvider can read IConfiguration without pulling in a full
ASP.NET Core dependency into the Infrastructure layer. Logged in
references.md.
Related docs¶
- Plain-language version: ../plain/study-media.md
- security.md — the
ApiTokencross-origin trust gate this feature reuses. - api-reference.md — full endpoint reference.
- references.md — YouTube Data API and iTunes Search API links.