Skip to content

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.

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.

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.csYouTubePlaylistClient.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:

  1. Explicit opt-in confirmIYouTubeStudyService.CreatePlaylistAsync(title, videoIds, confirmed, ct) takes a confirmed flag; when false, it is a pure no-op and makes no HTTP call at all (CreatePlaylist_WithoutConfirmation_IsNoOp_NoHttpCall). The frontend only sets confirmed: true after the user clicks through a two-step confirm box describing exactly what will happen (see Frontend below).
  2. Cross-origin trust gatePOST /api/study-media/playlist re-checks request.Confirmed server-side (never trusts the client alone) and requires ApiToken.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.
  3. 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 allUnconfiguredGoogleTokenProvider throws InvalidOperationException before 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 youtube scope was added to this app, so the token lacks that scope, or a 401/403/quota error. playlists.insert throws FlurlHttpException with 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. See CreatePlaylist_GoogleConnected_ButYouTubeScopeRejected_IsNoOp_NotUnhandled500.
  • The playlists.insert call fails ambiguously — anything that isn't a clean 4xx client rejection: Polly.Timeout.TimeoutRejectedException (a timeout), a FlurlHttpException with StatusCode == null (a response-less transport failure — connection reset, DNS failure, etc.), or a FlurlHttpException with 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 to ex.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. See CreatePlaylist_PlaylistInsertTimesOut_WarnsOfAmbiguity_NotBlindNoOp, CreatePlaylist_PlaylistInsertFailsWithNoResponse_WarnsOfAmbiguity_NotDefiniteRejection, and CreatePlaylist_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.

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.Message is 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 badged MATCH), 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 .../overall endpoints. Switching to overall mode also clears the previously selected job's plan and 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. See selectOverall clears the previously selected job's plan (no stale per-job UI/actions) in study.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 @empty pattern.
  • 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/podcasts if 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 — Flurl HttpTest fixtures (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 (no Fit keywords, only Fit.Summary) still searches via the title fallback, duplicate VideoIds across topics are deduplicated, playlist creation is a no-op without confirmation / without videos / without a Google connection, a confirmed+connected create posts to playlists then playlistItems with the user's bearer token, and CreatePlaylist_PlaylistCreated_ButAnItemAddFails_ReportsPartialSuccess_NotFailure proves a mid-batch item failure still reports the created playlist rather than a bare failure.
  • RestClientTests.csYouTubePlaylistClient_CreatePlaylist_On503_DoesNotRetry_NonIdempotentWrite proves 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 shape LinkedInJobsScraper produces; an empty title with no keywords returns empty rather than throwing.
  • ApiSmokeTests.cs/videos/overall and /podcasts/curated return 200; the playlist endpoint returns 401 without the trust token, 400 when unconfirmed even from a trusted origin, and 200 with 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-memory WebApplicationFactory host 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 Flurl HttpTest fixtures in PodcastSuggestionServiceTests.cs instead. (/videos/overall IS 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.ts covers 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.ts covers the playlist-create timeout exemption.
  • Both new source globs (**/Services/*.cs, **/Clients/*.cs) are already included in stryker-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.