Compare commits

...
13 Commits
Author SHA1 Message Date
gramps cc2d1ef622 TRAX: a share that is gone must never freeze the startup window
Startup took 44s and 42 of them were one line. Measured from startup.log:
OnStartup 08:42:59 -> MainWindow loaded 08:43:43, with the whole gap inside
LoadLayout, ending at

  TRAX: failed to load '\\192.168.50.86\llamaVault\Assets\Music\...':
  The network path was not found. (0x80070035)

LoadTrax called MusicPlayer.Load synchronously on the UI thread, and
new MediaFoundationReader(path) does not fail fast on a dead SMB session — it
burns the full ARP/TCP/SMB timeout and only then throws. The NAS had moved to
.87 after the power outage, so an unreachable path became a 40-second frozen
window on every launch.

The rule, from the creator: a probe may time out; a bind should return
immediately. Only one side of that is achievable here, and the reason matters —
an unreachable UNC path has no fast-fail at all, so probing it on the calling
thread is the worst possible probe. (A device open is the opposite: it normally
fails fast on a sharing violation, which is why this bug is network-specific.)

So: IsTrackReachableAsync runs the probe on a thread-pool thread under a
Task.WhenAny ceiling (TraxProbeBudget, 2s) and treats offline/missing/slow as a
normal state rather than an error. Only a probe that passed gets the real bind,
and that bind deliberately stays on the UI thread alongside every other
MusicPlayer mutation — moving the whole load off-thread would have introduced a
race against PlayTrax/PauseTrax that does not exist today. The orphan probe
thread stays parked until SMB gives up; it is not the thread anyone waits on.

One test, per the Good Dog rule: TraxProbeTests.
AHangingProbeIsAbandonedAtTheBudget_AndReportsOffline stands in a probe that
never returns and asserts the caller is released and told "offline". Drop the
ceiling and it fails on elapsed time — which is the frozen window returning.

Suite 381/381, 0 warnings.
2026-10-04 09:24:12 -07:00
gramps ad10176e8b docs: llamavault is .87 now, and SMB must be reached by name
The 2026-10 power outage rotated the NAS off its DHCP lease: 192.168.50.86 is
dead, 192.168.50.87 answers. The app had the literal IP baked into
Music.TrackPath, so TRAX blocked ~40s on an unreachable network path during
LoadLayout before giving up (0x80070035) — an unbounded SMB open on the
startup path.

Two facts banked so this is not re-derived:
- Reach the share as \\llamavault\llamaVault\... The Windows hosts file pins
  that name to the current IP, so an IP change is now a hosts-file edit, not
  a database edit inside a running app.
- The IP is DHCP. Ping before trusting any hardcoded path.

DB repointed to the name (Music.TrackPath, one row); backup kept alongside it
as ytLlive.db.bak-20261004-090001. Startup log truncated 16.7MB -> 118KB
(AppLog.AppendAllText holds no long-lived handle, so truncating in place is
safe).
2026-10-04 09:00:56 -07:00
gramps f3fed0a2a8 docs: purge the dead $29/$49 + Polar model from every tracked file, add the Y1 forecast
The pricing ruling landed twice (2026-09-21 one-time, then 2026-09-27 Store IAP
$10/mo + $400 lifetime) and the map never caught up. Six tracked files still told
a future session that billing was Polar and the price was $29/$49. A stale memory
map is worse than a stale comment: it is the file every session trusts, and this
one was actively steering work toward a deleted provider.

MONETIZATION.md (gitignored, so not in this commit) was rebuilt first:
- "Revenue projections" now carries a modelled Year 1 forecast —
  pessimistic $4.0K / base $15.4K / optimistic $53.3K net, ~$22K weighted.
  Funnel is installs -> 35-45% activate -> 1.5-3% of active users pay.
- Three findings recorded: Year 1 is ~97% lifetime transactions (subs net $392
  of $15.4K, so MRR is not a Year-1 planning input); conversion dominates price
  (~1pp of conversion = ~$7K net, and ADA 6(h) locks $10 anyway); subscriber
  churn moves Year 1 by under $200 and is a Year-2 problem.
- Break-even restated for $400: $25K net = 74 lifetime buyers, not 862.
  The price cut the volume requirement ~10x and raised the skill requirement
  to match.
- Market sizing willingness-to-pay row rewritten. The 100K-400K TAM was
  price-bound to $29-49 and is wrong at $400; revised ~20K-60K, flagged as an
  estimate rather than a sourced number.
- Flagged a real contradiction: $500K ARR needs ~4,900 concurrent subscribers,
  so the Exit section's ARR multiples do not describe what Years 1-3 produce.
  The defensible asset there is lifetime cash flow, not ARR.

Tracked files corrected to match:
- ai.md: paid-delta, pricing and billing bullets rewritten; Polar product id,
  org id, LCYT- key prefix and LLAMAFOUNDER/LLAMA50 marked as deleted;
  15% share + net-after-tax recorded with the ADA 6(b)(i) citation.
- TASKS.md: catalog row retitled as a teardown; the "still to verify — the
  price is not decided" note closed (it contradicted the ruling 20 lines
  below it); the OverlayHost "Polar unlocks alerts" open question marked
  resolved; Polar file-hosting deliverable removed from the release build.
- task-10: reframed as a teardown. Steps 4-5 marked as surviving, 1-2-3-6-7 as
  deleting, 8 as a compliance action (Velopack downloads AND executes, which
  Store policy 10.2.2 does not permit).
- task-36 item 5 (license-expiry reminders) struck — no key, no expiry, no
  lapse under Store IAP, so there is nothing to remind anyone about.
- Distribution.md 4.2 / 4.4 / 4.5 marked obsolete; 4.5's "already implemented"
  corrected, since PolarLicenseService is being deleted rather than shipped.
- README: "one-time license" -> "paid tier".

Historical Polar sections are retained deliberately — the reasoning is reusable
(merchant-of-record, fee comparison), only the conclusions are dead. Each is now
labelled so it cannot be mistaken for the plan.

No code touched.
2026-10-02 20:56:37 -07:00
gramps d95a23c6fb docs: the handoff was lying about three things — push state, TASK 50, test count
Correcting the handoff against the repo, per the rule that a stale map is worse
than no map. Three facts were wrong or missing, and the first two would have
made the next session's first action wrong.

1. PUSH STATE. "Last pushed: e7cded6" with two commits listed as unpushed.
   main was actually 9 commits ahead of origin/main. The line under-reported
   unpushed work by 7 commits, which is the optimistic-by-construction failure
   MyMistakes.md already names for a handoff whose heading promises the work
   is live. Now recorded as caught up.

2. TASK 50 WAS ABSENT, AND IT CARRIES A LIVE BUG, NOT A QUEUE ITEM. The task
   was carved 2026-09-28 and its mechanism section was CORRECTED the same day
   against the creator's own channel: 20+ private videos, all content-less
   placeholders. That is not a deferred feature, it is the real explanation of
   the 2026-09-26 "I cannot get private test recordings saved" report the map
   had been carrying as a closed item. liveBroadcasts.insert succeeds and
   creates the placeholder; the RTMP push / LIVE transition / archive chain is
   what fails, so no video content is ever produced. Added as an OPEN entry
   with the single diagnostic question that localises it (does ffmpeg reach
   a.rtmp.youtube.com, and does the broadcast reach LIVE), plus the sequencing
   constraint: do NOT flip enableAutomaticArchiving before TASK 50 ships, since
   archiving is currently the only route a recording takes to YouTube.

   It also carries an open 11.12 re-check — a publish feature makes the app
   PUSH creator content to a third party, so the IARC answers must not describe
   an app that cannot publish. That is a certification-ordering constraint, not
   a code detail, and it belongs where the next session will read it.

3. TEST COUNT WAS STALE. Recorded 379; the suite is 380 (the force-ssl scope
   test added one). Re-verified on a clean test-project build.

Also recorded a new flake honestly rather than leaving it for the next session
to rediscover as a regression: MasterLimiter_CapsTheLiveMix_OnThePipe failed
once on a full-suite run with Expected 0.891 / Actual 0, then passed 27/27 as a
class run and 380/380 on an immediate re-run. It reads the live mix off the
named pipe, so a ZERO is the pipe not being fed yet, not the limiter
misbehaving — the same whole-suite scheduling family as the RealAppHost STA
landmine. Recorded with "re-run before believing it".

Next list: the RTMP diagnostic sorts first because it is an observation rather
than a build and it gates a queued task; the packaging unit remains the next
CODE unit, which is not a reordering of the creator's priorities.

verify.sh: clean build, 0 warnings, 380/380, scope check passed.
2026-09-29 08:28:33 -07:00
gramps 2569c09617 task 50: correct mechanism — creator's channel proves stream never lands 2026-09-28 12:11:10 -07:00
gramps 3637950df5 task 50: one-button publish the local recording (creator ruling 2026-09-28) 2026-09-28 12:04:55 -07:00
gramps 967df83925 tests: one source-assertion helper, so the prose trap can't recur
The trap I hit twice today — a file-wide DoesNotContain matching the doc
comment that explains the ban — was possible because three tests each
hand-rolled their own File.ReadAllText + path-walker, two of them mine. Fixed
at the root rather than per-test: SourceAssert now owns path walking and
literal extraction, with Literal(memberName, params parts) and Scopes() that
assert on the parsed VALUE. Tests that genuinely assert structure (the #if
DEBUG arm in InstanceIsolationTests, the absence of a whole API in the
locator) still read the file themselves, because those claims cannot be
reduced to a value — the helper is for literal facts only.

Shipping the fix introduced two defects, both caught by the build: a method
named Path shadowed System.IO.Path and broke the error message that names the
missing file, and Literal(fileName, memberName) invited a call that silently
resolved to a nonexistent path. Argument order is now required-context-first
so a wrong path is visible at the call site.

Suite 380/380, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag flaked
once mid-session with no ytLive instance running and passed in isolation and
on re-run — the environment flake already recorded in HANDOFF.md, not a
regression from this change.
2026-09-28 11:53:52 -07:00
gramps b3edc2e42e Drop the redundant force-ssl scope, and reject the playlistItems "fix"
force-ssl is gone from the requested scope. Its grant ("see, edit, and
permanently delete your videos, ratings, comments and captions") is a strict
subset of auth/youtube's ("Manage your YouTube account"), and Google's own
liveBroadcasts.insert reference lists auth/youtube as an accepted ALTERNATIVE
to force-ssl, so every endpoint this app calls — channels, liveBroadcasts,
liveStreams, liveChat, playlistItems — is covered by the one we keep. Two
consent lines became one, and one fewer scope to justify. Removing a scope
cannot narrow capability when the scope being kept is the broader of the two,
and there is no forced re-consent: a refresh token keeps the scopes it was
originally granted, so existing sessions are untouched.

The playlistItems change is NOT being made, and the map entry recommending it
was wrong. It came out of the verification hunt on the theory that public data
should not ride on user credentials. Walking the code kills it: ChannelStats-
Service is built with one HttpClient, and channels?mine=true on the same
service genuinely needs the session. Splitting it means a second unauthenticated
client PLUS a Google API key, since every YouTube Data API request needs a
credential and the call 403s without one. So the fix would ship a new
extractable, quota-abusable credential inside the binary to replace a token the
app already holds, buying zero verification-surface reduction (the data is
public, no scope involved) and zero correctness. Strictly worse. Recorded as a
do-not-do so it is not "helpfully" reintroduced later.

Test: ExactlyOneScopeIsRequested_AndItIsTheBroadOne asserts on the const's
parsed VALUE rather than scanning the file — a file-wide DoesNotContain
fails on the doc comment that names force-ssl to explain its removal, which is
the same trap I hit and documented earlier today in the locator test. Suite
380/380, 0 warnings.
2026-09-28 09:45:35 -07:00
gramps 4fcf76289c TASK 48 item 1: bundle ffmpeg — the app no longer downloads an executable
Store policy 10.2.2 forbids dynamic code inclusion, and this was the only
third-party binary the app ever downloaded and ran. It had already failed in
production: the pin aged out of BtbN's 14-day retention and the download 404'd
on the creator's first real recording attempt (2026-09-01). Bundling kills
that failure class permanently and deletes the startup network dependency.

- scripts/fetch-ffmpeg.sh populates a gitignored ./ffmpeg from a pinned
  month-end BtbN LGPL-shared artifact, SHA-256 verified before extraction,
  idempotent, FFMPEG_ZIP= override. 135 MB of DLLs in git would tax every
  commit on this 9p tree; the URL + hash are the audit trail LGPL §6 wants.
- ytLive.csproj copies ffmpeg\** to output and publish (Always), and a
  GuardBundledFfmpeg target fails the BUILD if the folder is missing, so a
  fresh clone finds out before a take rather than during one.
- FfmpegLocator loses the downloader, the zip extraction and the
  %APPDATA%\ytLlive\tools cache. Order is now bundle -> PATH, deliberately
  inverted: a PATH ffmpeg may be a different major version or a GPL build,
  and the bundle is what was pinned, license-vetted and tested. A missing
  bundle throws loudly; the old "found something on disk" path could satisfy
  a missing bundle with a stale copy, and silent drift is the worst failure
  mode for a dependency certification inspects.
- ffplay.exe (18 MB) is not shipped; nothing calls it.
- THIRD-PARTY-NOTICES.txt: "downloads" -> "bundled" throughout, and the stale
  build tag autobuild-2026-08-09-13-03 — the tag that 404'd — corrected to
  autobuild-2026-08-31-13-27 with the exact artifact name and archive hash.
  OpenH264 made precise: compiled INTO avcodec-63.dll, no separate binary.

Checked whether other third-party binaries needed bundling: no. e_sqlite3.dll
and WebView2Loader.dll already ship via NuGet into runtimes\win-x64\native\,
OpenH264 is inside avcodec-63.dll, the rest are managed. Two gaps remain that
bundling cannot fix, recorded in the map: the WebView2 Evergreen runtime is a
machine prerequisite (TASK 48 item 2), and Velopack's updater downloads and
executes, so its teardown is a compliance action.

Tests: 16 locator facts, suite 379/379, 0 warnings. The integration test runs
the bundled ffmpeg -version, which is the only assertion that proves the
sibling libav*.dll resolved — presence checks pass with the DLLs missing and a
shared build then fails to start. TheLocatorCarriesNoNetworkCapability keeps
10.2.2 from rotting back.

Analysis: TASKS/research-store-certification.md §4.
2026-09-28 09:38:21 -07:00
gramps eb0dd91126 docs: OAuth verification is PARKED, not the next step — the map was lying
The 2026-09-27 entry sat under a "NEXT STEP" heading and framed the Google
verification as the top priority. It was neither un-attempted nor un-blocked:
the attempt had already failed on a Google-side bug and the creator had
already ruled it unimportant for where we sit. A "next step" heading lends a
dead end borrowed authority, and the next session would have opened the
console instead of the editor.

Records the ruling in the terms that change behaviour: attempted, died on
Google's side, ruled unimportant, do not re-attempt. States the cost of
tabling it honestly — an accepted 7-day refresh-token expiry while the
consent screen sits in Testing, which is why the creator kept having to
re-consent. The sensitive-vs-restricted classification was never read and is
moot until a second real user or Store submission.

The mechanism of the Google-side bug was never recorded and is left
deliberately blank rather than reconstructed — a confident fiction in a
memory map is the one sentence the next session would trust. The project
number and console link stay in ai.md:233; they cost a hunt and are still true.

The two code scope cleanups survive the tabling (neither needs verification
to land): drop the redundant force-ssl scope, and move the uploads-playlist
fetch off the user's session token. The channels?mine=true call on the same
service legitimately needs the session — flagged so it is not "fixed" later.

No code touched. Test state unchanged.
2026-09-28 09:25:27 -07:00
gramps 4873ef85ea docs: record the Google Cloud project owner + the OAuth verification blocker
Creator-confirmed: the Cloud project (number 198374416003, from the ClientId) is
owned by llamachile.shop@gmail.com. Recorded because finding it cost a hunt, and
because the trap was assuming it was the same account as the YouTube channel used
in the app -- they are separate logins.

Also records what the repo had nothing on at all: the OAuth verification path, the
Testing-mode 100-user cap + 7-day refresh-token expiry that explains the repeated
re-consent, the sensitive-vs-restricted fork (a paid annual CASA assessment if
restricted), and two scope cleanups -- auth/youtube.force-ssl is redundant, and
ChannelStatsService's playlistItems.list needs no OAuth scope at all.

Docs only, no code touched.
2026-09-28 09:04:54 -07:00
gramps 44344c9327 fix: Change Account signed out BEFORE the browser opened
Reported 2026-09-27: click avatar -> Change Account -> cancel in the Google
chooser -> back to a signed-out app, then "cannot sign-in because the callback
url was not released".

Two independent defects:

1. ChangeAccountAsync ran `await SignOutYouTubeAsync(); await SignInAsync();`.
   SignOutYouTubeAsync -> TokenStore.Clear() -> deletes ytLlive.auth, so the
   working account's token was destroyed before the browser even opened. It is
   now just `await SignInAsync()`: success overwrites the token via the
   sessionChanged hook, cancel/failure keeps the current session. The explicit
   sign-out was never needed.

2. Abandoning the browser gives the app no signal, so the OAuth attempt held
   localhost:8765 for its full 5-minute timeout and a retry could not bind. A
   new AuthenticateAsync now cancels and awaits the abandoned attempt first.
   That takeover is serialised by a SemaphoreSlim: the first version cleared
   the in-flight marker before awaiting, so a caller arriving in that window saw
   a free port and raced to bind it -- reproducing the very failure it was
   written to prevent. My own test caught that; reasoning had "proved" it fine.

Also adds an `openBrowser` ctor seam so tests never shell out to a real
browser, and corrects the ai.md line that documented "Logout / Change Account ->
SignOutYouTubeAsync" as intended behaviour.

Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort
Full suite 370/370, 0 warnings. Coverage gap noted in HANDOFF.md: the ordering
fix has no automated test because _youtubeAuth is hardcoded in MainViewModel
and ChangeAccountAsync is private.

The reported cross-instance effect is NOT this cause and stays open -- no code
path can sign out a second instance (per-instance TokenStore, local delete, no
server-side revoke).
2026-09-27 16:02:05 -07:00
gramps 536676f11a fix: millisecond segment in the recording filename so two instances can't clobber a take
The dev multi-instance use case (one instance screen-capturing the other for
marketing/training videos) exposed a real bug. BuildStartPath in
MainViewModel.Recording.cs builds the start name with no dedup:

    var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
    var path = Path.Combine(folder, name);

Only FinalizeRecordingAsync calls UniquePath. So two instances that start
recording in the same minute computed the identical path
(ty-20260927-1454-0000.mp4) and both ffmpeg processes wrote one file -- a
corrupt take, not an error message.

Creator's fix (2026-09-27): add a millisecond count to the filename timestamp.
New format is ty-<yyyyMMdd>-<HHmm>-<fff>-<hh2mm2>.mp4. Both the start name and
the final name derive from _recordStartTime, so the ffff segment survives the
rename-on-stop and the final name cannot collide with another same-minute take
either. No UniquePath call needed, which keeps the start path a pure function of
the timestamp and the existing collision suffix behaviour intact.

Tests: 2 new (the same-minute guard and the rename-survival guard), 5 existing
assertions updated for the format. RecordingSaveDialogTests' stem fixtures
updated to match. 369/369 pass, 0 warnings.

Note: the first test run reported 367/367 because only ytLive.csproj had been
built -- the stale test DLL never picked up the new tests. The real count is 369.
2026-09-27 15:37:46 -07:00
27 changed files with 1574 additions and 494 deletions
+5
View File
@@ -1,6 +1,10 @@
bin/
obj/
Helpers/OAuthCredentials.cs
# Bundled FFmpeg (135 MB of DLLs). Fetched once by scripts/fetch-ffmpeg.sh, which pins
# the URL + SHA-256; the build then copies it to the output. Never committed — see the
# script header for why the hash, not the binaries, is the audit trail.
*.user
*.suo
.vs/
@@ -11,3 +15,4 @@ MONETIZATION.md
MARCOM.md
CREDENTIALS.md
.aider*
ffmpeg/
+37 -10
View File
@@ -318,9 +318,17 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
**Caveat:** Self-contained single-file means ~80-120MB due to bundled .NET runtime. For smaller size, use framework-dependent publishing (requires .NET 8 runtime on user machines) — drops to ~5-10MB.
### 4.2 Distribution via Polar
### 4.2 Distribution via Polar — ⛔ OBSOLETE (2026-09-27)
**Product setup on Polar:**
> **Superseded by Microsoft Store MSIX + Store IAP** (ruling in
> `TASKS/task-48-distribution-msix.md` item 0). The Store re-signs, hosts, updates, and takes
> payment — so file hosting, license keys, checkout, the customer portal, device deactivation and
> key rotation are **all** handled by Microsoft and none of it needs building. Retained below as a
> record of the model that was designed and abandoned. ⚠️ **The `$29`/`$49` one-time prices in this
> section are dead** — the settled pricing is **`$10/mo` or `$400` lifetime**, net of a verified
> 15% Store share.
**Product setup on Polar (as designed, never shipped):**
1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list).
2. Add two benefits:
@@ -345,6 +353,11 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
- If a key is compromised, customer or merchant can rotate it via Polar.
- Previous key stops validating immediately. Status, usage, limits, expiry preserved.
**What the Store does instead:** the customer installs from the Store listing, pays through Store
checkout, and the app reads the entitlement the OS already holds. There is no key to enter, no
activation call, no portal to visit, and no offline-grace period to maintain — which also retires
the whole "network flaky → app thinks I'm expired" bug class in §4.5.
### 4.3 Code Signing
> ⚠️ **The cert choice below is a live decision, and the old plan's EV recommendation is
@@ -393,7 +406,13 @@ Set-AuthenticodeSignature -FilePath "LlamaCasty.exe" `
**Storage:** Certificate `.pfx` file stored in GitHub Secrets (or Azure DevOps secure files). Never committed to the repo.
### 4.4 Update Mechanism
### 4.4 Update Mechanism — ⛔ OBSOLETE (2026-09-27)
> **Superseded by Store updates.** Microsoft delivers and updates the app from the listing, so
> there is nothing to check and no manifest to host. The Velopack updater currently in
> `App.xaml.cs` **downloads *and executes***, which Store policy **10.2.2** does not permit — its
> removal is a **compliance action**, not optional cleanup (TASK 48 item 3). The Polar-native design
> below is retained as a record of the approach that was rejected; it is not an option.
**Polar-native approach (no custom infrastructure):**
@@ -428,10 +447,18 @@ public class UpdateCheckService
- Download link points to Polar's customer portal (not direct download).
- This adds one external dependency (GitHub) but keeps Polar as the source of truth.
### 4.5 Licensing Logic (already implemented)
### 4.5 Licensing Logic — ⛔ BEING DELETED (2026-09-27)
> ⚠️ **"Already implemented" was true as of 2026-09-21 and is no longer.** `PolarLicenseService`
> and `Helpers/PolarLicense.cs` are being **removed** as part of the TASK 10 teardown (TASK 48 item 0).
> Store IAP replaces the whole validation hop: `IsPremium` is derived from the **OS-supplied Store
> entitlement**, so there is no network call, no key, and — importantly — **no 14-day offline-grace
> machine to protect**, which retires the entire "network flaky → app thinks I'm expired" bug class.
> What survives is only `IsPremium` → `BrandFlashEnabled = !value`. Retained below as the record of
> what shipped and why it no longer applies.
```csharp
// Services/PolarLicenseService.cs — already ships
// Services/PolarLicenseService.cs — shipped under Polar; being deleted
// Calls Polar's customer-portal validation endpoint (no auth required)
// Survives obfuscation — property names excluded via [Obfuscation(Exclude = true)]
[Obfuscation(Exclude = true)]
@@ -444,11 +471,11 @@ public class LicenseValidationResult
}
```
**What's already done:**
- `PolarLicenseService` validates keys against Polar API.
- `OrganizationId` is hardcoded (safe — it's public knowledge, tied to our Polar account).
- License state persists in `LayoutStore.Settings` (SQLite) with offline grace period.
- `PremiumUrl` points to Polar checkout.
**What shipped under Polar (now being deleted):**
- `PolarLicenseService` validated keys against the Polar API.
- `OrganizationId` was hardcoded.
- License state persisted in `LayoutStore.Settings` (SQLite) with an offline grace period.
- `PremiumUrl` pointed to Polar checkout.
**What survives obfuscation:** The validation logic in `Services.dll` is obfuscated. An attacker who decompiles the exe sees only the UI calling an interface — the actual Polar API call is in the obfuscated DLL.
+190 -29
View File
@@ -1,8 +1,86 @@
# HANDOFF — current state
**Branch:** `main` (pre-1.0, no feature branches — creator ruling 2026-08-24).
**Last pushed:** `b2036a3`. This unit (reconciling the pricing ruling + verifying the Store
revenue share) is **docs-only** — no code touched, no build, no tests run.
**Caught up 2026-09-28:** `main` is pushed to `origin/main` (was 9 commits ahead — the handoff
line below used to under-report the unpushed work by 7 commits, which is exactly the failure
`MyMistakes.md` warns about: a stale handoff makes the reader's next action wrong).
## 🚨 FIXED 2026-09-27 — "Change Account" no longer signs you out before the browser opens
**Reported as:** *"I clicked on my signed-in account pic, then clicked sign-in to another account. It
opened the browser page to login under a different account and I canceled the request. But when
returned to the app, I see logged out — and should not have happened! Cannot sign-in because the
callback url was not released."* Also reported as hitting **both** running instances.
**Two independent defects, and the second one was masking a race:**
1. **The sign-out ran first.** `ChangeAccountAsync` was `await SignOutYouTubeAsync(); await
SignInAsync();`. `SignOutYouTubeAsync` → `TokenStore.Clear()` → **deletes** `ytLlive.auth`. So the
working account's token was destroyed *before the browser even opened*; cancelling the chooser left
nothing. Now `ChangeAccountAsync` is just `await SignInAsync()` — success overwrites the token via
the `sessionChanged` hook, cancel/failure keeps the current session. No sign-out is needed at all.
2. **The retry couldn't rebind the callback port.** Abandoning the browser gives the app no signal, so
the attempt held `localhost:8765` for its full 5-minute timeout. A new `AuthenticateAsync` now
cancels and awaits the abandoned attempt before binding, **serialised by a `SemaphoreSlim`**
(`_authGate`) — without the gate, the takeover window let a third caller see a free port and race
to bind it, i.e. the takeover reproduced the original failure. My own test caught this.
**The cross-instance claim was NOT the cause** and is still open as a *separate* question: no code path
can sign out a second instance (`TokenStore` is per-`InstanceProfile.DataRoot`, `Clear()` is a local
file delete with no server-side revoke, and no second instance had a token at inspection time). Verify
both processes carried distinct `YTLIVE_INSTANCE` values and ran Debug builds before chasing it.
Test: `YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort`. Full suite
**370/370**, 0 warnings (build `ytLive.Tests.csproj`, not just the app project, or VSTest runs a stale
test DLL). Added an `openBrowser` ctor seam to `YouTubeAuthService` so tests never launch a real
browser. **Coverage gap:** the *ordering* fix (defect 1) has no automated test — `_youtubeAuth` is
hardcoded in `MainViewModel` and `ChangeAccountAsync` is private, so a real test would shell out to
the browser. Wiring an auth seam into `MainViewModel` is a deliberate follow-up, not a bug-fix extra.
## 🗄 PARKED — Google OAuth verification: attempted, hit a Google-side bug, tabled by ruling
**Creator ruling: unimportant for where we currently sit.** Verification is *not* a live blocker and
**must not be re-attempted, re-derived, or re-researched** without a new ruling. The 2026-09-27 entry
that preceded this one wrongly sat at `⏭ NEXT STEP` and framed it as the top priority — that was the
plan going in, not the outcome, and the outcome was never written down. Corrected 2026-09-28.
**What happened:** the verification attempt was made and failed on a bug **on Google's side** — not
something we can fix, work around, or retry our way past. ⛔ **The exact mechanism was never recorded
and is deliberately left blank** rather than reconstructed from memory: a confidently wrong note here
is worse than none, because the next session would trust it. Do not go looking for the cause.
**Owning account (creator-confirmed): `llamachile.shop@gmail.com` ("gramps").** Cloud project number
`198374416003` (from the ClientId) — banked in `ai.md:233`, **do not re-hunt it.** The one-hop console
link is `https://console.cloud.google.com/apis/credentials?project=198374416003` (picker defaults to
**"Recent"** — switch to **"All"**). ⛔ Not the same account as the YouTube channel signed into the app.
**What tabling costs, stated honestly:** if the consent screen is still in **Testing**, the 100-user
cap and the **7-day refresh-token expiry** both stand — that expiry is why the creator kept having to
re-consent. Parking verification does not fix that; it accepts it for the solo-use phase. It becomes
real work the moment a *second real user* needs to sign in, or at Store submission (TASK 48). The
sensitive-vs-restricted classification was never read and is moot until then.
**One of these two survives the tabling, and the other was a bad idea (corrected 2026-09-28):**
- ✅ **DONE — `Services/YouTubeAuthService.cs` no longer requests `auth/youtube.force-ssl`.**
It is **redundant**: its grant is *"see, edit, and permanently delete your YouTube videos,
ratings, comments and captions"*, a strict subset of `auth/youtube`'s *"Manage your YouTube
account"*, which is the scope we keep. Google's own `liveBroadcasts.insert` reference lists
`auth/youtube` as an accepted **alternative** to `force-ssl`, so every endpoint this app calls
(`channels`, `liveBroadcasts`, `liveStreams`, `liveChat`, `playlistItems`) is covered.
Two consent lines became one. **No forced re-consent** — a refresh token keeps the scopes it
was originally granted, so existing sessions are untouched; the reduced set applies at the
next fresh sign-in. Removing a scope cannot narrow capability when the one kept is broader.
- ❌ **NOT doing this, and the earlier note recommending it was wrong: moving
`playlistItems.list` off the user's session token.** It was written during the verification
hunt on the theory that "public data should not ride on user credentials". Walking the code
kills it: `ChannelStatsService` is built as `new ChannelStatsService(_youtubeAuth)` with **one**
`HttpClient`, and `channels?mine=true` (same service) **genuinely needs the session**. To split
it you would need a second unauthenticated client **plus a Google API key**, because every
YouTube Data API request needs a credential — without a key the call 403s. So the "fix" means
**shipping a new extractable credential in the binary, subject to quota abuse, to replace a
token the app already holds.** It buys **zero** verification-surface reduction (the data is
public; no scope is involved) and zero correctness. Strictly worse. ⛔ Do not "fix" this later
without a new ruling.
## ✅ DECIDED 2026-09-27 — pricing: `$10/mo` subscription, or `$400` lifetime, **no annual**
@@ -59,21 +137,45 @@ ONLY paid delta. Store IAP changes how `IsPremium` is *obtained*, never what it
this file.** The old "~$69 floor" and "don't break the launch-price promise" notes refer to
the old Polar storefront and are **not** current.
### The first code unit: bundle ffmpeg (TASK 48 item 1)
### ✅ DONE 2026-09-28 — TASK 48 item 1: bundle ffmpeg (was the "first code unit")
Two real defects the research surfaced — **neither is fixed yet**, this was a docs unit:
The app no longer downloads an executable at runtime. `scripts/fetch-ffmpeg.sh` populates a
gitignored `ffmpeg/` from a **SHA-256-verified** pinned month-end BtbN artifact; `ytLive.csproj`
copies it to output *and publish* and **fails the build** if it's missing; `FfmpegLocator` lost its
downloader, its zip extraction and the `%APPDATA%\ytLlive\tools` cache, and now resolves
**bundle → PATH** with a loud, actionable failure. The pin that 404'd is gone, so the
2026-09-01 failure class cannot recur. Suite **379/379 at that point** (380 now — see Test
state); 16 locator facts including an
integration test that **executes the bundled binary** (which is what proves the sibling DLLs
resolve). `THIRD-PARTY-NOTICES.txt` corrected — it still named the *stale* tag
`autobuild-2026-08-09-13-03`, i.e. the one that 404'd.
1. **⛔ `FfmpegLocator` downloads an unsigned exe from GitHub and runs it.** Store policy
**10.2.2** (dynamic code inclusion) verbatim, *and* the root cause of the 2026-09-01
failure — the pin aged out of BtbN's 14-day retention and the download **404'd on the
creator's first real recording attempt**. `FfmpegLocator.cs:30-35` records the incident but
still reads like a design choice. **Fix: bundle it.** Also deletes the startup network
dependency.
2. **⛔ `Distribution.md:318` recommended a $400+/yr EV certificate for a SmartScreen bypass
Microsoft removed in March 2024.** Fixed last commit — had it shipped, it would have cost
$400+/yr to buy what $150 buys. Now moot anyway (MSIX is signed by Microsoft), but the
lesson in `MyMistakes.md` stands: a policy citation is a claim about **scope**, not just
text.
**Fresh-clone cost, deliberately accepted:** `./scripts/fetch-ffmpeg.sh` once (~65 MB). 135 MB of
DLLs in git would tax every commit on this 9p tree; the URL + SHA-256 are the audit trail.
**Answering "other 3p binaries needing bundling?" — no, verified.** ffmpeg was the only
download-and-execute. `e_sqlite3.dll` + `WebView2Loader.dll` already ship via NuGet
(`runtimes\win-x64\native\`), OpenH264 is compiled *into* `avcodec-63.dll`, the rest are managed.
Two gaps remain that bundling cannot fix, and they are the next real risks: the **WebView2 Evergreen
runtime** is a machine prerequisite (item 2's manifest dependency + a graceful-failure path), and
**Velopack's updater downloads and executes**, so its teardown in step 3 is a compliance action.
### The next code unit: the packaging project + `Package.appxmanifest` (TASK 48 items 2–3)
Full trust, `webcam`/`microphone`, English only; Velopack deleted. Add the `DefaultRecordFolder()`
comment in the same unit (the mediumIL invariant below becomes load-bearing the moment the manifest
lands). Full-trust writes under a real package identity are still **unverified** — confirm on a
real packaged build before trusting them with recordings.
Carried over from the research unit, still open:
- **⛔ `Distribution.md:318` recommended a $400+/yr EV certificate for a SmartScreen bypass
Microsoft removed in March 2024.** Fixed then — had it shipped, it would have cost
$400+/yr to buy what $150 buys. Now moot anyway (MSIX is signed by Microsoft), but the
lesson in `MyMistakes.md` stands: a policy citation is a claim about **scope**, not just
text.
- **WebView2 Evergreen runtime** is a machine prerequisite the app will hard-fail without. The
manifest needs the framework dependency, and WebView2 init needs a graceful error instead of a
crash. New, found while bundling ffmpeg.
### ⛔ Two invariants that break silently if touched
@@ -85,6 +187,42 @@ Two real defects the research surfaced — **neither is fixed yet**, this was a
the 11.12 certification answer. No chat history, moderation log, analytics, or crash payload
may be added without re-arguing 11.12 first.
## 🚨 OPEN 2026-09-28 — the RTMP push never lands: recordings reach YouTube as EMPTY placeholders
**This is a live bug, not a queued feature**, and it reframes a complaint the map previously
treated as closed. It was found on 2026-09-28 while scoping TASK 50, and the handoff had no
entry for it at all — corrected here.
**What the creator observed:** the channel carries **20+ private videos, all content-less
placeholders** (creator-inspected, 2026-09-28). This is the real explanation of the
2026-09-26 report "I still cannot get private test recordings saved" — it was never true that
the app could not *find* an archived VOD. **No video content is ever produced on the channel.**
**Mechanism (verified against the code AND the channel):** `liveBroadcasts.insert` succeeds and
creates the placeholder; everything after it is what fails. Content only exists if all three of
these land, and at least one does not:
1. broadcast reaches **LIVE** via `liveBroadcasts/transition`,
2. ffmpeg actually pushes RTMP to the bound stream key,
3. the broadcast ends and YouTube transcodes it into a VOD.
`enableAutomaticArchiving` is **never set**, so YouTube's `true` default applies — the archiving
route exists but has nothing to archive. Broadcasts are inserted `private`
(`Services/YouTubeStreamService.cs:49`), so the orphans are not public, and the app has **no
delete path** — the creator clears each one by hand in Studio.
**⛔ The one diagnostic question, worth answering before anything else queued:** during a real
test stream, **does ffmpeg reach `a.rtmp.youtube.com`, and does the broadcast reach LIVE?**
That single observation localises the failure to one of three steps. Do not go hunting beyond
it until it is answered.
**Sequencing constraint — do not flip the flag early.** `enableAutomaticArchiving: false` is the
correct end state (TASK 50 will upload the local file deliberately), but archiving is
**currently the only route a recording takes to YouTube** — flipping it before TASK 50 ships
would leave test streams with nothing at all and re-create the 2026-09-26 complaint in a worse
form. Ship the upload, flip the flag in the same task, never before.
Detail and the open 11.12 re-check: [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md).
## Landmines
- **Stale fact inside `TASKS.md` (left alone, flagged here per the Scope Lock).** The "1.0 gates"
@@ -109,10 +247,22 @@ Two real defects the research surfaced — **neither is fixed yet**, this was a
## Test state
**367/367 as of `938c5b3`.** Not re-run for this unit — docs only, no code touched.
**380/380, 0 warnings, re-verified 2026-09-28 on a clean test-project build** (the count is 380,
not the 379 recorded earlier — the `force-ssl` scope test added one). Build the **test** project,
not just the app project, or VSTest runs a stale DLL.
**⚠️ New flake, do not chase it as a regression:** on the *first* full-suite run,
`AudioPipelineTests.MasterLimiter_CapsTheLiveMix_OnThePipe` failed with `Expected 0.891,
Actual 0` — a **zero**, not a slightly-off value, which is the signature of the pipe not being
fed yet rather than the limiter misbehaving. It passed 27/27 as a class run and 380/380 on an
immediate full-suite re-run. It reads the live mix **off the named pipe**, so it is a
whole-suite scheduling artifact of the same family as the `RealAppHost` STA landmine below —
**if it ever goes red, re-run before believing it.**
## Uncommitted / untracked
- `ffmpeg/` — the 9 bundled binaries (~135 MB), **gitignored by design**. Populated by
`scripts/fetch-ffmpeg.sh`. Do not commit it.
- `MARCOM.md`, `MONETIZATION.md` — both edited (privacy-copy guard; the Polar-obsolete banner
and the re-opened-price note) and both **gitignored by design** (confidential business
files, `.gitignore:10-11`). They stay local, as intended. Same for `CREDENTIALS.md` —
@@ -120,23 +270,34 @@ Two real defects the research surfaced — **neither is fixed yet**, this was a
## Next
1. **Bundle ffmpeg (TASK 48 item 1)** — unblocked, first code unit, fixes a real user-facing
failure and clears the one hard certification gate.
2. **Settle the price** — one-time vs subscription, and the number. Then declare the IAP
products in Partner Center. **Blocks:** the Store listing, and the `OverlayHost.xaml` copy.
3. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — full trust,
`webcam`/`microphone`, English only; Velopack deleted. Add the `DefaultRecordFolder()`
comment in the same unit.
4. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`,
1. **Answer the RTMP diagnostic — an observation, not a build** (see the OPEN entry above).
One test stream, then read `startup.log` for whether ffmpeg reached YouTube and whether the
broadcast went LIVE. **Cheap, and it gates a queued task**, which is why it sorts first. It
does not reorder anything below — the packaging unit is still the next *code* unit.
2. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — the next code unit.
Full trust, `webcam`/`microphone`, English only; Velopack deleted. Add the
`DefaultRecordFolder()` comment in the same unit, and the WebView2 framework dependency +
graceful-failure path.
3. **TASK 50 — one-button "Publish this video"** (`TASKS/task-50-publish-recording.md`). Queued
behind the Store submission by its own precedence note ("MSIX first, publish second — unless
the creator reverses that"). Scoping is done; the build is not. ⛔ Do **not** flip
`enableAutomaticArchiving` until it ships. It also carries an **open 11.12 re-check**: a
publish feature makes the app *push* creator content to a third party, so the IARC answers
must not go to certification describing an app that cannot publish.
4. **Declare the IAP products in Partner Center** — the price is **settled** ($10/mo, $400
lifetime, no annual, 15% verified), so this is unblocked. **Blocks:** the Store listing and the
`OverlayHost.xaml` copy. ⛔ The Individual-vs-Company account question is still unanswered —
get it right before enrolling.
5. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`,
`MainViewModel.License.cs`, the `OverlayHost.xaml` string, the `csproj`/`App.xaml.cs`
Velopack sites. `IsPremium` ← Store entitlement. **Do this as one unit** — a half-torn-down
licensing path is worse than either end state.
5. **Verify the Store revenue-share rate** — before step 2, not after.
6. **Vertical recording verification** — the compositor tier is proven by
`Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop`; the *record* path has never
been run. `FramePump.cs:645` flags off-size tiers as a known follow-up.
7. **WACK + certification notes + the privacy policy** (TASK 48 items 4–5) — the policy must
state camera/mic is OS-gated, nothing is retained, and nothing is fetched at runtime.
state camera/mic is OS-gated, nothing is retained, and **nothing is fetched at runtime**
(now literally true — the ffmpeg bundle is what makes that sentence defensible).
8. **Post-session efficacy report** — roll up `CurrentHealth` (dropped frames, duration, health
message) when a stream or recording ends. `SessionTeardownTests` is the natural home.
9. **Measure whether the Windows camera toggle gates DShow** — gates all privacy-forward copy
@@ -144,9 +305,9 @@ Two real defects the research surfaced — **neither is fixed yet**, this was a
10. **TASK 36:212 stale "shipped" line** — one-line fix, needs a hand.
**Dropped from the list** because the ruling made them moot: multi-instance's ffmpeg tools-dir
race (item 1 makes it disappear), `scripts/publish.sh` + the `LlamaCasty.exe` rename (the Store
packages and delivers — *revisit only if we ever ship outside the Store*), and the alert-gating
copy (the wrong string dies with the Polar teardown in step 4).
race (item 1 deleted the tools dir entirely), `scripts/publish.sh` + the `LlamaCasty.exe` rename
(the Store packages and delivers — *revisit only if we ever ship outside the Store*), and the
alert-gating copy (the wrong string dies with the Polar teardown in step 3).
**Everything else in the v1 queue:** stream resilience (32), bandwidth step-down (33), scheduled
streams (34), scene-linked audio (35), the master limiter (12), text source (3.16), reward
+185
View File
@@ -1177,3 +1177,188 @@ well-formed, unverified* claim got to speak without a check — a dead EV-certif
policy number with no scope, a refund lever I did not control. **Sound reasoning about a
mechanism I had not verified is the failure mode, not the safety net.** Before any external
mechanism drives a decision, name the one fact that would falsify it, then go get that fact.
## 2026-09-27 — "Change Account" signed out BEFORE the browser opened: destroying state you cannot get back
**The symptom:** the creator clicked their avatar → Change Account, cancelled in the Google account
chooser, came back **signed out of a perfectly working account**, and then could not sign in again
("the callback url was not released"). They also observed it hitting *both* running instances.
**The cause, and it is embarrassing in its simplicity.** `ChangeAccountAsync` was:
```csharp
await SignOutYouTubeAsync(); // → TokenStore.Clear() → File.Delete(ytLlive.auth)
await SignInAsync(); // the browser has not even opened yet
```
The sign-out ran **first**, before any possibility of success, and `SignOutYouTubeAsync` does not
"log out" — it **deletes the DPAPI token file**. So the moment the creator committed to the intent of
switching accounts, the old account was already unrecoverable. Cancelling the browser flow then had
nothing to fall back to. The explicit sign-out was not merely redundant, it was the bug: success
already overwrites the token through the `sessionChanged → TokenStore.Save` hook.
**The transferable rule — order of operations on unrecoverable state:**
> **Never destroy the working state before the replacement is secured. Authenticate first, then
> replace.** If the new value can fail, cancel, or be abandoned, the old value is the *only* thing
> standing between the user and a re-authentication loop — so it must outlive the attempt, not
> precede it.
The tell for this class of bug: a call named for an *intent* ("Change Account") that internally
performs a *destructive reset* before doing the requested work. "Change" implies A→B, and the naive
implementation of A→B is `Reset(); Set(B)`. The safe one is `B = Compute(); if (B != null) Commit(B)`.
**The second defect was hiding behind the first, and the fix for it was also a race.** Abandoning the
browser gives the app no signal, so the OAuth attempt just sat on its loopback port `localhost:8765`
for the full 5-minute timeout; a retry could not bind and reported the callback URL as "not released".
Cancel-and-await-the-previous fixed the serial case, but my first version still had a window:
`Interlocked.Exchange(ref _authInFlight, null)` nulls the field *before* awaiting the old attempt, so
a third caller arriving in that window sees "nothing in flight" and races to bind the same port
anyway — **the takeover reintroduced the exact failure it was written to prevent.** A `SemaphoreSlim`
around cancel→await→bind made it atomic. My own test caught this; reasoning alone had "proved" it
correct. The generalisation: **when you serialise a handover, the whole handover is one critical
section — clearing the "who is doing this" marker early is the same bug as not having the lock.**
**Also worth recording: the cross-instance part of the report was NOT the cause.** The creator
believed one instance's sign-out hit both. It could not have — `TokenStore.DefaultPath` resolves
through `InstanceProfile.DataRoot` and `Clear()` only deletes a local file, never a server-side revoke,
and no second instance had a token to clear. The two claims were independent, and believing they were
linked would have sent me to debug the wrong subsystem. **One report can carry two unrelated defects;
split the report into claims and check each against the code before building a theory on it.**
### Known timing flake: `NotificationAreaIntegrationTests.Info_Auto_Dismisses_And_Error_Sticks`
Failed once on 2026-09-27 with "info toast never auto-dismissed" after an 8s wait, then passed in
isolation and on a clean full-suite run. It is a real-time WPF toast timer, so it loses under
parallel load — **it is not a signal that your change broke notifications.** Before believing it,
re-run it alone (`vstest --Tests:Info_Auto_Dismisses_And_Error_Sticks`); only treat it as yours if it
fails in isolation too. See also the two other suite-level timing traps already recorded above
(the one-tick pipe read, and blocking waits on the shared `RealApp` STA thread).
### Writing the *plan* into the handoff instead of the *outcome* — the OAuth verification trap
On 2026-09-27 I wrote a HANDOFF entry titled "record the Google Cloud project owner + the OAuth
verification blocker". What it actually contained was the two console questions I was about to go
answer, filed under `⏭ NEXT STEP` — which made the top of the file say *the next thing we do is
verify with Google*. The verification attempt had **already failed on a Google-side bug** and the
creator had **already ruled it unimportant for where we sit**. I knew the ruling; the map still said
the opposite, and the next session would have opened the console before opening the editor.
**The failure is not that the fact is missing — it's that the file is optimistic by construction.**
A "next step" heading is a promise that this is un-attempted and un-blocked, so parking a dead end
under one is worse than omitting it: the heading lends the dead end borrowed authority it does not
deserve. When an attempt dies on someone else's bug, the handoff must say **attempted → died on their
side → ruled unimportant → do not re-attempt**, in those words, where the next session will read it.
Two corollaries, both paid for here:
- **Record the ruling, not just the discovery.** "Here is the project number" is cheap and I wrote
it. "Here is why we stopped, and what it cost us to stop" is the part that changes behaviour, and
it is the part I skipped. The real cost — an accepted 7-day refresh-token expiry while the consent
screen sits in Testing — was never written anywhere.
- **Leave the unknown blank, loudly.** I cannot now say what the Google-side bug *was*. Writing a
plausible mechanism would be fabrication, and it would be the most trusted sentence in the file,
because it is the one that explains everything else. `HANDOFF.md` now carries an explicit
"deliberately left blank, do not reconstruct it" marker. An honest gap beats a confident fiction
in a memory map; in a memory map especially, since the whole point is that the next session skips
the search.
**Generalisation: a memory map entry that makes the reader's *next action* wrong is a bug, not an
omission.** When a fact is superseded by a ruling, the ruling wins the headline — the stale detail
belongs underneath it, labelled as what it is.
### Bundling a pinned binary: verify the pin, then delete the whole download path (TASK 48 item 1)
FFmpeg was the app's only download-and-execute third-party binary, which is exactly what Store
policy 10.2.2 names — and it had already failed in production (2026-09-01, the pin aged out of
BtbN's 14-day retention and the download 404'd mid-task). Bundling it took one unit. Four things
worth keeping, in the order I learned them:
**1. Check whether you already have the bytes before you re-fetch them.** The creator's local
`%APPDATA%\ytLlive\tools` already held the pinned build from the September download. Extracting the
pinned zip fresh and comparing SHA-256 per file proved all 9 were byte-identical, which upgraded
"probably the right build" to "provably the right build" for the price of one download. **An
existing cache is a hypothesis, not a fact — but it is a cheap hypothesis to test, and testing it
is what makes the bundle trustworthy.**
**2. Flip the probe order and say why, in the code comment.** The locator probed PATH first
("the user's own install wins"). Once the binaries are in the package, that is backwards: a PATH
`ffmpeg` may be a different major version or a **GPL** build, and the bundle is the one you pinned,
license-vetted and tested. PATH stays as the escape hatch, which is what it was actually for.
I would not have caught this without asking "which of these two is the one I tested?"
**3. Delete the fallback, do not just deprioritise it.** The old ladder had
`PATH → tools-cache → download`, and the tools-cache step meant a **missing bundle could be
silently satisfied by a stale copy**. For a dependency that Store certification inspects, silent
drift is the failure mode you least want: the package is wrong and nothing says so. A missing
bundle now throws, naming the folder and the fetch script. Likewise a build-time guard in the
csproj, so a fresh clone learns before a take rather than during one.
**4. 135 MB of DLLs in git is a real, recurring tax — the hash is the better audit trail.** The
temptation is to commit the binaries so "the build just works". On a 9p-bridged working tree that
tax is paid on *every* commit, status and checkout, forever, to serve a one-time packaging step.
Shipping a `fetch-ffmpeg.sh` that pins the URL and **verifies SHA-256 before extracting** keeps
the repo small, keeps the network dependency at build time instead of run time (which is what the
policy actually forbids), and gives the LGPL written-offer something better to point at than an
opaque blob: a URL, an artifact name, and a hash a reviewer can recompute.
**The test that earned its keep:** the integration test runs the bundled `ffmpeg.exe -version`.
Presence checks would all have passed with the DLLs missing — a shared build simply fails to
start, so *executing* it is the only assertion that proves the `libav*.dll` siblings resolved. When
a binary's correctness depends on its neighbours being adjacent, test the neighbours.
**Also: check what you just claimed about the licences.** `THIRD-PARTY-NOTICES.txt` said OpenH264
"ships inside the FFmpeg build". Verified rather than assumed: the encoder is compiled *into*
`avcodec-63.dll` (8 references in that DLL) and there is no separate OpenH264 binary to ship — so
the claim was right but imprecise, and it now says precisely that, with the check and its date.
The same file was still naming build tag `autobuild-2026-08-09-13-03` — **the tag that 404'd.**
A licence record that names a stale artifact is worse than no licence record, because it reads as
verified. When you pin something, the pin and the paperwork must move in the same commit.
### I repeated, in the same session, the mistake I had just written down
The ffmpeg-bundling unit added `TheLocatorCarriesNoNetworkCapability`, which failed on its first
run because a file-wide `DoesNotContain("Download")` matched that file's own doc comment — the one
saying "nothing is downloaded". I diagnosed it properly (bare word matches hit prose; use
call-shaped patterns) and wrote the lesson into this file. **Two hours later, in the very next
change, I wrote `DoesNotContain("force-ssl")` against `YouTubeAuthService.cs` and it failed for
the identical reason**: that file now names `force-ssl` in its doc comment *precisely to explain
why it was removed*. Writing the lesson down did not prevent the repeat; the lesson was not yet
load-bearing in the place the decision gets made.
**The actual fix, and it generalises past source-scan tests:** don't scan the *file* for a
token, **extract the value and assert on the value.** A `Regex` that pulls
`private const string Scope = "([^"]*)";` and then splits the captured string into scopes tests
the thing that matters — "exactly one scope, and it is the broad one" — and is structurally
incapable of matching prose. The general form: **when a fact must be asserted about a literal
that the surrounding documentation is also allowed to discuss, assert on the parsed literal, not
on the text that contains it.** A test that reads a file and greps it is a test whose failure
mode is "the comment changed", and that is how source-scan tests get deleted rather than obeyed.
**Meta-lesson worth more than the fix:** I made the same class of error twice inside one session
while actively documenting it. Repeating a documented mistake is not a knowledge failure — the
knowledge was fine — it is a *check* failure. The check that would have caught it: when a
`DoesNotContain` is aimed at a file, ask "does this file's own documentation have a legitimate
reason to contain the string?" If yes, the assertion is aimed at the wrong target.
### Extracting the duplicated helper created two fresh bugs in the same commit (2026-09-28)
Pulling the three hand-rolled `RepoFile` path-walkers into one `SourceAssert` helper — the fix for
the repeated prose-matching trap — shipped with two defects of its own, both caught by the build:
- **A method named `Path` shadowed `System.IO.Path` inside the class**, so the failure message that
wanted to *name the missing file* was itself a compile error (`CS0119`). Renamed to `RepoPath`.
A helper whose name collides with a BCL type is a trap for the next reader, not just for me.
- **The signature invited the bug it was meant to prevent.** `Literal(string fileName, string
memberName)` looked like it took a file name, but the body resolved the path from the repo root,
so the only legal call was a full directory path — and the call site passed
`"YouTubeAuthService.cs"`, which resolves to a path that does not exist. The failure was a
bare `FileNotFoundException` with no hint. Flipping the argument order to
`Literal(memberName, params string[] parts)` makes a wrong path *visible at the call site*
instead of buried in a resolved path.
**The generalisation:** a parameter shape that cannot express the common legal call is a defect
even when the compiler accepts it. `fileName` + `memberName` looks fine and is wrong; the honest
shape puts the variable part last and the required context first. This is the same family as the
prose-matching lesson — **the aim is to make the wrong thing hard to write, not to detect it
afterward** — and I got it wrong in the very commit written to apply that idea.
+1 -1
View File
@@ -54,7 +54,7 @@ text sources, chat on-stream, the social bar, mic/desktop audio with auto-duck a
recording, variable quality tiers with auto step-down, one-click go-live + scheduling, stream
resilience (auto-reconnect inside YouTube's grace, one-click back on air), built-in monetization
awareness (reward capture, session reports, YPP journey tracker), Alerts — all free and ungated;
the one-time license only removes the branding flash.
the paid tier only removes the branding flash.
## Structure
+47 -119
View File
@@ -1,153 +1,81 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using ytLive.Helpers;
namespace ytLive.Services.Encoder;
/// <summary>
/// The default <see cref="IFfmpegLocator"/>: probe PATH first (the user's own
/// install wins), then the cache in <c>%APPDATA%\ytLlive\tools</c>, then pull the
/// pinned BtbN **lgpl-shared** build (TASK 4 ship step 2). The shared variant is a
/// deliberate licensing choice: dynamic linking means LGPL compliance is "license
/// text + source offer", with no static-relink (LGPL §6) material required. The
/// shared zip puts <c>ffmpeg.exe</c> plus the <c>libav*.dll</c> family in <c>bin/</c>,
/// so both are extracted — Windows resolves the DLLs from the exe's own directory.
/// Search dirs, tools dir, and the downloader are constructor-injected so tests
/// fake the network and stay on a temp directory.
/// The default <see cref="IFfmpegLocator"/>: resolve the <b>bundled</b> FFmpeg that ships
/// next to the app, falling back to PATH for anyone who deliberately installed their own.
///
/// <para><b>Nothing is downloaded.</b> This type used to pin a BtbN release URL, download
/// the zip on first run, and execute it — which is Windows Store policy 10.2.2 (dynamic
/// code inclusion, cited in <c>TASKS/research-store-certification.md</c> §4) and already
/// cost a real failure: the pin aged out of BtbN's 14-day retention and the download
/// <b>404'd on the creator's first real recording attempt</b> (2026-09-01). The binaries
/// are now fetched at BUILD time by <c>scripts/fetch-ffmpeg.sh</c> (pinned URL + SHA-256)
/// and copied into <c>ffmpeg/</c> beside the executable.</para>
///
/// <para><b>Order is bundled-first, deliberately.</b> The old code probed PATH first
/// ("the user's own install wins"). With a bundle in the package that is the wrong way
/// round: a random PATH <c>ffmpeg</c> may be a different major version or a GPL build, and
/// the bundle is the one we pinned, license-vetted, and tested against. PATH stays as the
/// escape hatch, which is what it is actually good for.</para>
///
/// <para>The lgpl-<b>shared</b> variant is a licensing choice, not a packaging one: dynamic
/// linking makes LGPL compliance "license text + source offer" with no static-relink
/// (LGPL §6) material required. That means the <c>libav*.dll</c> family must sit in the
/// same folder as the exe — Windows resolves them from the executable's own directory.</para>
/// </summary>
public sealed class FfmpegLocator : IFfmpegLocator
{
public const string FileName = "ffmpeg.exe";
/// <summary>Sibling probe binary — extracted alongside ffmpeg so the media
/// <summary>Sibling probe binary — bundled alongside ffmpeg so the media
/// frame-rate probe (<see cref="ytLive.Services.FfmpegFrameRateProbe"/>) can
/// derive its path from the located ffmpeg directory.</summary>
public const string ProbeFileName = "ffprobe.exe";
/// <summary>
/// Pinned BtbN LGPL-shared win64 build — <c>autobuild-2026-08-31-13-27</c>, the
/// ffmpeg N-126342 build (immutable tag; recorded in TASKS.md per the licensing rule).
/// Deliberately a MONTH-END tag: BtbN retains the last ~14 dailies but month-end
/// builds for ~2 years — the previous pin (a 2026-08-09 daily) aged out of retention
/// and 404'd on a cold cache exactly as ai.md predicted (2026-09-01, the first real
/// recording attempt). Dated tags carry versioned asset names (ffmpeg-N-…-win64-lgpl-shared.zip);
/// extraction is name-agnostic.
/// </summary>
public const string PinnedUrl =
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip";
/// <summary>Folder, relative to the app base directory, that the build copies the
/// bundled build into. One folder for the exe and every DLL.</summary>
public const string BundleFolderName = "ffmpeg";
/// <summary>Base directory the bundle is resolved against. Injectable so tests can
/// point at a temp tree; defaults to the running app's base directory.</summary>
private readonly string _bundleRoot;
private readonly string[] _searchDirs;
private readonly string _toolsDir;
private readonly Func<string, CancellationToken, Task<byte[]>> _downloader;
public FfmpegLocator(
string[]? searchDirs = null,
string? toolsDir = null,
Func<string, CancellationToken, Task<byte[]>>? downloader = null)
public FfmpegLocator(string[]? searchDirs = null, string? bundleRoot = null)
{
_searchDirs = searchDirs ?? ParsePath();
_toolsDir = toolsDir ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools");
_downloader = downloader ?? DefaultDownload;
_bundleRoot = bundleRoot ?? AppContext.BaseDirectory;
}
public async Task<string> LocateAsync(CancellationToken cancellationToken = default)
public Task<string> LocateAsync(CancellationToken cancellationToken = default)
{
var bundled = Path.Combine(_bundleRoot, BundleFolderName, FileName);
if (IsUsable(bundled)) return Task.FromResult(bundled);
foreach (var dir in _searchDirs)
{
if (string.IsNullOrEmpty(dir)) continue;
var candidate = Path.Combine(dir, FileName);
if (File.Exists(candidate)) return candidate;
if (IsUsable(candidate)) return Task.FromResult(candidate);
}
var cached = Path.Combine(_toolsDir, FileName);
if (File.Exists(cached) && new FileInfo(cached).Length > 0) return cached;
try
{
var zip = await _downloader(PinnedUrl, cancellationToken).ConfigureAwait(false);
if (zip.Length == 0)
throw new IOException($"FFmpeg download from {PinnedUrl} returned an empty payload.");
Directory.CreateDirectory(_toolsDir);
ExtractBinaries(zip, _toolsDir);
return cached;
}
catch (HttpRequestException ex)
{
// The old gap: this exception type escaped unwrapped, so a dead pin surfaced
// as a raw "Response status code does not indicate success: 404" from the
// frame pump. Locator failures are IOException with an actionable message.
AppLog.Write(ex, "FFmpeg locator: download failed");
throw new IOException(
$"FFmpeg download failed ({ex.Message}). The pinned build may have aged out of " +
"retention — update FfmpegLocator.PinnedUrl, or install FFmpeg (LGPL-shared build) " +
"and make sure 'ffmpeg' is on PATH.", ex);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
{
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
throw;
}
// Deliberately loud. The previous cache-then-download ladder had a "found
// something on disk" path that could satisfy a missing bundle with a stale copy,
// which is exactly the silent-drift a certification-gated dependency must not
// have. If the package forgot ffmpeg/, the app says so and stops.
throw new IOException(
$"FFmpeg was not found. The bundled copy is missing from " +
$"'{Path.Combine(_bundleRoot, BundleFolderName)}' — this build is incomplete " +
"(reinstall or re-fetch with scripts/fetch-ffmpeg.sh). Installing an LGPL-shared " +
"FFmpeg and putting 'ffmpeg' on PATH also works.");
}
/// <summary>
/// Extract <c>ffmpeg.exe</c> and every <c>*.dll</c> into <c>toolsDir</c> via a
/// staging directory, so a failed extract never leaves a partially-populated
/// cache behind (the previous good cache stays until every move succeeds).
/// </summary>
private static void ExtractBinaries(byte[] zip, string toolsDir)
{
var staging = toolsDir + ".stage";
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
Directory.CreateDirectory(staging);
try
{
using var stream = new MemoryStream(zip, writable: false);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
var exe = archive.Entries.FirstOrDefault(
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
ExtractOne(exe, Path.Combine(staging, FileName));
var probe = archive.Entries.FirstOrDefault(
e => e.FullName.EndsWith("/" + ProbeFileName, StringComparison.OrdinalIgnoreCase));
if (probe != null)
ExtractOne(probe, Path.Combine(staging, ProbeFileName));
foreach (var entry in archive.Entries)
{
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
ExtractOne(entry, Path.Combine(staging, Path.GetFileName(entry.FullName)));
}
foreach (var file in Directory.GetFiles(staging))
File.Move(file, Path.Combine(toolsDir, Path.GetFileName(file)), overwrite: true);
}
finally
{
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
}
private static void ExtractOne(ZipArchiveEntry entry, string destination)
{
var temp = destination + ".tmp";
try
{
using (var source = entry.Open())
using (var target = File.Create(temp))
source.CopyTo(target);
File.Move(temp, destination);
}
finally
{
if (File.Exists(temp)) File.Delete(temp);
}
}
private static async Task<byte[]> DefaultDownload(string url, CancellationToken ct)
{
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
return await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
}
private static bool IsUsable(string path) =>
File.Exists(path) && new FileInfo(path).Length > 0;
private static string[] ParsePath()
{
+14 -8
View File
@@ -2,11 +2,17 @@ namespace ytLive.Services;
/// <summary>
/// Pure filename logic for local recordings (TASK 18). The user-requested naming
/// is <c>ty-yyyymmdd-hhmm-hh2mm2.mp4</c>: <c>ty</c> is the brand marker, the first
/// <c>hhmm</c> is the hour+minute the recording STARTED (saved), and the second
/// <c>hh2mm2</c> is the hour+minute of the recording's LENGTH. Length is only known
/// when recording ends, so the file is created with a <c>0000</c> length and renamed
/// on stop (see <see cref="FinalizeFileName"/>).
/// is <c>ty-yyyymmdd-hhmm-fff-hh2mm2.mp4</c>: <c>ty</c> is the brand marker, the first
/// <c>hhmm</c> is the hour+minute the recording STARTED (saved), <c>fff</c> is
/// milliseconds on the same start timestamp, and the last <c>hh2mm2</c> is the
/// hour+minute of the recording's LENGTH. Length is only known when recording ends,
/// so the file is created with a <c>0000</c> length and renamed on stop (see
/// <see cref="FinalizeFileName"/>).
/// <para>The <c>fff</c> exists so two instances started in the same minute cannot
/// compute the same path — the dev multi-instance use case (record one instance
/// with the other) would otherwise have both ffmpeg processes writing one file.
/// Both names derive from the same <c>_recordStartTime</c>, so the segment survives
/// the rename.</para>
/// </summary>
public static class RecordingFile
{
@@ -14,14 +20,14 @@ public static class RecordingFile
public const string StartLengthSuffix = "0000";
public static string BuildStartName(DateTime start)
=> $"ty-{start:yyyyMMdd}-{start:HHmm}-{StartLengthSuffix}";
=> $"ty-{start:yyyyMMdd}-{start:HHmm}-{start:fff}-{StartLengthSuffix}";
/// <summary>Final name with the real recording length (fractional hour+minute),
/// e.g. 75 minutes → <c>ty-20260829-1405-0115.mp4</c>.</summary>
/// e.g. 75 minutes → <c>ty-20260829-1405-000-0115.mp4</c>.</summary>
public static string BuildFinalName(DateTime start, TimeSpan length)
{
var hh2 = (int)length.TotalHours;
var mm2 = length.Minutes;
return $"ty-{start:yyyyMMdd}-{start:HHmm}-{hh2:00}{mm2:00}";
return $"ty-{start:yyyyMMdd}-{start:HHmm}-{start:fff}-{hh2:00}{mm2:00}";
}
}
+76 -8
View File
@@ -15,22 +15,60 @@ public class YouTubeAuthService
{
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
private const string Scope = "https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl";
/// <summary>
/// The single scope the app requests: <c>auth/youtube</c> ("Manage your YouTube
/// account"). It is the **broadest** YouTube scope and is unavoidable — no narrower one
/// covers live broadcast create/transition, and Google's own <c>liveBroadcasts.insert</c>
/// reference lists it as an accepted alternative to <c>youtube.force-ssl</c>, so every
/// endpoint this app touches (<c>channels</c>, <c>liveBroadcasts</c>, <c>liveStreams</c>,
/// <c>liveChat</c>, <c>playlistItems</c>) is covered by it.
///
/// <para><c>auth/youtube.force-ssl</c> used to be requested alongside it and was
/// <b>dropped 2026-09-28 as redundant</b>: its grant is "see, edit, and permanently delete
/// your YouTube videos, ratings, comments and captions", a strict subset of what
/// <c>auth/youtube</c> already grants. Requesting both meant two consent lines and two
/// scopes to justify for one capability. Removing a scope can never narrow capability,
/// because the scope being kept is the broader of the two.
///
/// No forced re-consent: a refresh token keeps the scopes it was originally granted, so an
/// existing session is unaffected; the reduced set applies from the next fresh sign-in.</para>
/// </summary>
private const string Scope = "https://www.googleapis.com/auth/youtube";
private readonly string _clientId;
private readonly string _clientSecret;
private readonly HttpClient _http;
private readonly Action<YouTubeChannel>? _sessionChanged;
private readonly Action<string> _openBrowser;
/// <summary>Serialises "take over from the previous attempt". Without this, two
/// overlapping calls both see a free port and race to bind it, which is the same
/// failure the cancel-and-await is meant to prevent.</summary>
private readonly SemaphoreSlim _authGate = new(1, 1);
/// <summary>The attempt currently holding the loopback callback port, if any.
/// A creator who abandons the Google account chooser gives the app no signal, so
/// the attempt simply waits out its 5-minute timeout while still holding port
/// 8765 — and a retry in that window cannot bind. A new attempt cancels and
/// awaits the abandoned one first, which releases the port (creator report
/// 2026-09-27: "cannot sign-in because the callback url was not released").
/// Both fields below are only touched while holding <see cref="_authGate"/>.</summary>
private Task<YouTubeChannel?>? _authInFlight;
private CancellationTokenSource? _authCts;
public YouTubeChannel? CurrentChannel { get; private set; }
public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null,
Action<YouTubeChannel>? sessionChanged = null)
Action<YouTubeChannel>? sessionChanged = null,
Action<string>? openBrowser = null)
{
_clientId = clientId;
_clientSecret = clientSecret;
_http = http ?? new HttpClient();
_sessionChanged = sessionChanged;
// Seam: the real flow shells out to the default browser, which a test must not do.
_openBrowser = openBrowser ?? (url =>
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }));
}
/// <summary>Replaces the current session (used when a saved token loads at startup).</summary>
@@ -65,23 +103,53 @@ public class YouTubeAuthService
public async Task<YouTubeChannel?> AuthenticateAsync()
{
const int port = 8765;
var redirectUri = $"http://localhost:{port}/oauth2/callback";
if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret))
return null;
// Taking over from an abandoned attempt has to be atomic: cancel it, wait for
// it to actually let go of the port, then bind. Letting the gate go before the
// bind reopens the race the takeover exists to close.
Task<YouTubeChannel?> attempt;
await _authGate.WaitAsync().ConfigureAwait(false);
try
{
var previousCts = _authCts;
_authCts = null;
previousCts?.Cancel();
if (_authInFlight is { } previous)
{
try { await previous.ConfigureAwait(false); } catch { /* its result is irrelevant */ }
_authInFlight = null;
}
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
_authCts = cts;
_authInFlight = attempt = RunAuthFlowAsync(cts.Token);
}
finally
{
_authGate.Release();
}
return await attempt.ConfigureAwait(false);
}
private async Task<YouTubeChannel?> RunAuthFlowAsync(CancellationToken cancellationToken)
{
const int port = 8765;
var redirectUri = $"http://localhost:{port}/oauth2/callback";
using var listener = new HttpListener();
listener.Prefixes.Add($"{redirectUri}/");
listener.Start();
Process.Start(new ProcessStartInfo(GetAuthorizationUrl(redirectUri)) { UseShellExecute = true });
_openBrowser(GetAuthorizationUrl(redirectUri));
HttpListenerContext context;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
context = await listener.GetContextAsync().WaitAsync(cts.Token);
context = await listener.GetContextAsync().WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
+28 -11
View File
@@ -29,7 +29,7 @@
| 07 | Meter scaling amplification | ✅ Done | [`TASKS/task-07-meter-scaling.md`](TASKS/task-07-meter-scaling.md) |
| 08 | Audio milestone | ✅ SHIPPED 2026-08-14 | [`TASKS/task-08-audio-milestone.md`](TASKS/task-08-audio-milestone.md) |
| 09 | YouTube Live Stream Management | ⏳ In progress — items 1–3, 4 shipped; item 5 open; item 6 locked (TASK 36) | [`TASKS/task-09-live-stream-management.md`](TASKS/task-09-live-stream-management.md) |
| 10 | Monetization: watermark-only one-time license + Polar billing | 🔶 In progress — steps 1–7 shipped; **now a TEARDOWN: Polar is deleted in favour of Store IAP (2026-09-27)** | [`TASKS/task-10-monetization.md`](TASKS/task-10-monetization.md) |
| 10 | Monetization: watermark-only paid delta (branding flash) | 🔶 **TEARDOWN, not a build** — Polar deleted in favour of Store IAP (2026-09-27); `$10/mo` + `$400` lifetime, no annual | [`TASKS/task-10-monetization.md`](TASKS/task-10-monetization.md) |
| 11 | Post-pause polish batch (creator's 8 review issues) | ✅ SHIPPED 2026-08-15 | [`TASKS/task-11-polish-batch.md`](TASKS/task-11-polish-batch.md) |
| 12 | Master limiter on the live mix | ☐ Queued | [`TASKS/task-12-master-limiter.md`](TASKS/task-12-master-limiter.md) |
| 13 | Social media launch kit | 🔶 Scoped — queued after v1 | [`TASKS/task-13-social-launch-kit.md`](TASKS/task-13-social-launch-kit.md) |
@@ -63,8 +63,9 @@
| 45 | TEST-tab chat fix #2: insert body must declare `snippet.type` (400 MISSING_REQUIRED_FIELD) | ✅ Done (2026-09-25) | [`TASKS/task-45-chat-insert-type.md`](TASKS/task-45-chat-insert-type.md) |
| 46 | Drawers: click outside the rail closes whichever is open — TEST added to the existing Stream Settings + YPP dismiss behavior | ✅ Done (2026-09-25) | [`TASKS/task-46-drawer-click-outside-close.md`](TASKS/task-46-drawer-click-outside-close.md) |
| 47 | Alert box video: built-in/custom alert clip (+ six-animation fallback) with read-time fade in/out + ticker (now in the preview too, 3 display methods) + alert volume in the live mix | ✅ Done (2026-09-26) | [`TASKS/task-47-alert-videos.md`](TASKS/task-47-alert-videos.md) |
| 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; item 1 (bundle ffmpeg) is the next code unit | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) |
| 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28** | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) |
| 49 | Chat profanity filter (local, opt-in, non-persistent, user word list) | ☐ Queued (2026-09-27) | [`TASKS/task-49-chat-profanity-filter.md`](TASKS/task-49-chat-profanity-filter.md) |
| 50 | One-button "Publish this video" (resumable upload of the local recording) | ☐ Queued (2026-09-28) | [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md) |
---
@@ -119,8 +120,9 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
- **Shipping / release build (creator-queued 2026-09-26)** — no publish config exists yet:
the csproj has only `OutputType=WinExe` + `TargetFramework`, so a plain `dotnet publish`
is **framework-dependent** (customer needs the .NET 8 Desktop Runtime preinstalled).
`Distribution.md` targets a self-contained ~80–120MB `LlamaCasty.exe` uploaded to Polar
as a File Download benefit. Two deliverables:
`Distribution.md` targets a self-contained ~80–120MB `LlamaCasty.exe`. ⛔ **The Polar
File-Download benefit is dead** — the Store hosts and updates the app, so there is no upload
step (TASK 48). Two deliverables:
1. **`scripts/publish.sh`** wrapping
`dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained true -o ./publish/win-x64`,
and **asserting the output contains no test/xunit artifacts**. (The compile side needs
@@ -234,11 +236,24 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
- **Unchanged:** the watermarks posture. Free gets everything; the branding flash stays the
ONLY paid delta (TASK 36 item 2). Store IAP changes how the bit is *obtained*, never what
it *gates*.
- **Still to verify (does not block packaging):** current Store revenue-share terms — ⚠️ do
not assume any percentage, verify before setting a price; and the one-time vs
subscription shape of the IAP tier (the storefront is decided, the price is not).
- **First code unit: item 1 — bundle ffmpeg instead of downloading it.** Unblocked,
recommended on every route, and it fixes a real user-facing failure.
- **Both formerly-open questions are now CLOSED (2026-09-27).** Store revenue share is
**verified at 15%, subscriptions included** (ADA v8.10 §6(b)(i) — read from the PDF, not a
search excerpt), and the IAP tier shape is **decided: `$10/mo` subscription + `$400`
lifetime, no annual**, declared in Partner Center as two products. Full detail in
`TASKS/task-48-distribution-msix.md` item 0. ⚠️ Do **not** re-open the fee question without
re-reading the ADA, and do not *assume* 15% if the terms ever move.
- **✅ item 1 SHIPPED 2026-09-28 — bundle ffmpeg instead of downloading it.** The app no
longer fetches an executable at runtime (Store policy 10.2.2, and the pin 404'd on the
creator's first real recording on 2026-09-01). `scripts/fetch-ffmpeg.sh` populates a
gitignored `ffmpeg/` from a **SHA-256-verified** pinned month-end BtbN artifact; the csproj
copies it to output/publish and **fails the build** if it is missing; `FfmpegLocator` lost its
downloader and now resolves **bundle → PATH**. Immutable tag recorded per the licensing rule:
`autobuild-2026-08-31-13-27`. `ffplay.exe` is not shipped. Verified: ffmpeg was the **only**
download-and-execute third-party binary — `e_sqlite3.dll`/`WebView2Loader.dll` already ship
via NuGet and OpenH264 is compiled into `avcodec-63.dll`. **Remaining gaps are not bundling
problems:** the WebView2 Evergreen *runtime* is a machine prerequisite (item 2), and
**Velopack's updater downloads and executes — its teardown is a compliance action.**
Detail: `TASKS/task-48-distribution-msix.md` §1.
- **TASK 10 is now a teardown, not a build** (2026-09-27) — Store IAP deletes the Polar
licensing path. The Polar fee tables, the perpetual-key model, and the customer-portal
plumbing in `MONETIZATION.md` (gitignored, local) are obsolete; the **watermark-only
@@ -299,8 +314,10 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
- Still queued from earlier: `scripts/publish.sh` (self-contained win-x64, no test artifacts,
Debug-only `InternalsVisibleTo`, no WPF trimming), and the `LlamaCasty.exe` assembly rename
(three pack URIs incl. `MainWindow.xaml:14` — do not rename `%APPDATA%\ytLlive`).
- Decide the open business question: `OverlayHost.xaml` copy says Polar unlocks *alerts* too, but
alerts are not gated by `IsPremium`. Either fix the copy or gate the alerts — do not leave it.
- ✅ **RESOLVED — do not re-open.** The `OverlayHost.xaml` copy claimed Polar unlocked *alerts*;
alerts were never gated by `IsPremium`. The file is **deleted outright** by the TASK 10 teardown
(TASK 48 item 0), so the copy is gone with it. The "or gate the alerts" option is **permanently
closed** — there is no feature gating, ever.
---
+55 -25
View File
@@ -1,12 +1,36 @@
# TASK 10 — Monetization: watermark-only one-time license + Polar billing
# TASK 10 — Monetization: watermark-only paid delta (branding flash)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
**Goal:** a **paid tier that removes the branding flash and nothing else.** Every feature is free
— Alerts, social bar slots, voice filters, TRAX, recording, analytics. The free tier is the *full*
app. ⛔ **There is no feature gating, ever** — revenue argument, not taste: the free tier's reach
is the funnel, and the flash is an ad running inside other people's content, so a free user with a
flash every 30–60s is already paying in exposure.
**Goal:** a **one-time perpetual license** via Polar.sh that removes the branding watermark. All features
are free — the only difference between free and paid is the watermark. (Model changed 2026-09-21 from
annual subscription to one-time "own it": $29 founder / $49 list lifetime. See `MONETIZATION.md` →
Pricing for the reasoning.)
**⚠️ STATUS: THIS IS A TEARDOWN, NOT A BUILD (2026-09-27).** The billing provider is
**Microsoft Store IAP**, not Polar. Everything below describing Polar is **dead** and is retained
only as history. **Do not resurrect any of it.** The teardown's file-by-file list lives in
[`TASKS/task-48-distribution-msix.md`](task-48-distribution-msix.md) item 0 — **work from there,
not from this file.**
**Pricing — ✅ DECIDED 2026-09-27: `$10/mo` subscription, or `$400` lifetime. No annual.**
| Tier | Price | Net after 15% | Role |
|---|---|---|---|
| Free | $0 | — | Everything, plus the branding flash. The funnel. |
| **Pro monthly** | **$10/mo** | ~$8.50/mo | The default. Low-risk entry. |
| **Pro lifetime** | **$400** | ~$340 | The hedge. Committed / subscription-hostile buyers. |
- **15% Store revenue share, subscriptions included** — verified from the ADA v8.10 PDF, §6(b)(i).
Net receipts are after VAT/GST, so commission is not paid on pass-through tax.
- **Break-even is month 40**, so the two tiers barely compete — which is why lifetime is safe to
offer at launch rather than saving it for later.
- ⛔ **No annual tier** — Microsoft does not pro-rate and the developer cannot refund the
forfeited remainder. ⛔ **No `.99` prices.** ⛔ **No discounted upgrade SKU.**
- **ADA §6(h): raising the price disables auto-renew ⇒ `$10` is effectively locked for life.**
- ⛔ **Superseded pricing, do not cite:** the 2026-09-21 one-time model (`$29` founder → `$49`
list lifetime) and the earlier `$49.99/yr → $99/yr` subscription.
**Related work — monetization awareness (2026-09-01, ungated, free):** this task's billing is the
*payer* side; the *product* also carries built-in, ungated monetization awareness for every creator —
@@ -19,26 +43,32 @@ narrative, not a separate task number — it ships in its own code slices with o
**Scope: v1** (2026-09-01 complete-v1 ruling — there is no v1.x): slice order = reward-event capture →
session report → journey tracker → Alerts (TASK 3 item 20).
**Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).**
**Business details (pricing, Partner Center product declaration) in `MONETIZATION.md` (gitignored).**
**Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, ⚠️ currently configured as
$99/yr **subscription** — must be re-created as a **one-time** product: $29 founder / $49 list lifetime)
**Checkout:** `llamacasty.com` → Polar hosted page (~80% configured)
**Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, `once`)
**Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` can be omitted from API calls)
**Key prefix:** `LCYT-`
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
### Status: 🔶 TEARDOWN — steps 1–7 shipped under Polar, all now to be removed
### Status: 🔶 In progress — steps 1–7 shipped, Velopack update URL pending
1. ✅ **Billing provider** — Polar (polar.sh) chosen and configured. Product, price, license key benefit, discounts, checkout link all created.
2. ✅ **License key entry + verification UI** — About overlay swap panel (same pattern as Licenses panel). Text input + "Activate" button + status message. "Enter License Key" button in the hub.
3. ✅ **Offline entitlement** — `LayoutStore` Settings table: `LicenseKey`, `LicenseValidatedAt`, `IsPremium`. 14-day offline grace period. Startup re-validates online, falls back to cache.
4. ✅ **BrandFlash → watermark toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor.
5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c
6. ✅ **"Unlock Premium" button** — `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub.
7. ✅ **Licensing model** — startup re-validates against Polar API. **Perpetual key (2026-09-21): the
license never expires, `IsPremium` never lapses, and the flash does not return.** The former
renewal/lapse path is dead. Offline grace period (14 days) still applies to the validation call only.
8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration).
Steps 1–7 below are a **record of what was built**, not a description of what ships. The Polar
licensing path is being deleted because Store IAP supplies the same bit from the OS entitlement.
1. ⛔ **Billing provider** — was Polar (polar.sh). **DELETED.** The product, checkout link, and the
discount codes `LLAMAFOUNDER` / `LLAMA50` no longer exist. Store IAP products are declared in
**Partner Center**, not Polar.
2. ⛔ **License key entry + verification UI** — was the About-overlay swap panel
("Enter License Key"). **DELETING.**
3. ⛔ **Offline entitlement** — was `LayoutStore` Settings (`LicenseKey`, `LicenseValidatedAt`,
`IsPremium`) + a 14-day offline grace period + startup re-validation. **DELETING.** The OS
supplies license state, so the entire offline-grace machine goes with it — along with the whole
"network flaky → app thinks I'm expired" bug class.
4. ✅ **BrandFlash → watermark toggle** — `IsPremium` setter flips `BrandFlashEnabled = !value`, so
the flash stops the moment the entitlement flips. **This survives the teardown.** Under Store IAP
`IsPremium` is derived from the Store entitlement rather than a remote HTTP call. One bit,
locally cached, refreshed by the OS. ⚠️ **This is the whole product surface of the paid tier.**
5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone. **Survives.**
6. ⛔ **"Unlock Premium" button** — `PremiumUrl` pointed at Polar checkout. **DELETING**; the Store
handles purchase. Paid users see zero license UI.
7. ⛔ **Licensing model / renewal-lapse path** — already dead before the Store ruling (a perpetual
key never expires). Now superseded again: **Store IAP has no keys at all.**
8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. **TEARDOWN is a
compliance action, not cleanup:** the updater downloads *and executes*, and Store policy 10.2.2
does not permit it. Tracked as [`TASKS/task-48-distribution-msix.md`](task-48-distribution-msix.md)
item 3; the Store updates the app instead.
+2 -2
View File
@@ -15,7 +15,7 @@
4. ✅ Top-bar REC + ON-AIR pill toggles, status lights (REC green when recording, ON-AIR green when live), dynamic primary-button text, account status-light tooltip
5. ✅ Record-folder persistence (`LayoutStore` `RecordFolder` key) + picker (`ChooseRecordFolderCommand`)
6. 🟡 Branding flash carries into local recordings — same frame path as streaming (verify in the running app)
7. ✅ Output folder `%APPDATA%\ytLlive\recordings\` default; auto-name `ty-<yyyymmdd>-<hhmm start>-0000.mp4` at start, **rename-on-stop** to `ty-…-<hh2mm2 length>.mp4` (numeric suffix on collision)
7. ✅ Output folder `%APPDATA%\ytLlive\recordings\` default; auto-name `ty-<yyyymmdd>-<hhmm start>-<fff>-0000.mp4` at start, **rename-on-stop** to `ty-…-<hh2mm2 length>.mp4` (numeric suffix on collision). The `<fff>` millisecond segment was added 2026-09-27 so two instances started in the same minute cannot compute the same path (dev multi-instance: one instance recording the other)
Remaining: running-app verification of the rename + dual output.
@@ -39,7 +39,7 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
1. ✅ **TASK 9.4** — Live chat (right panel) — wire `YouTubeChatService.Start()`, parse messages, render in panel. Foundation for chat box source.
2. ✅ **TASK 3.18** — Chat box source — renders chat ON the stream. Depends on TASK 9.4 (same message parsing).
3. ✅ **TASK 10** — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1-7 shipped; Velopack update URL pending).
3. ✅ **TASK 10** — watermark-only paid delta (brand flash). Steps 1–7 shipped under **Polar**, which is **now deleted in favour of Microsoft Store IAP** (2026-09-27) — TASK 10 is a **teardown**, and the Velopack updater goes with it (Store policy 10.2.2).
4. ✅ **TASK 19/23** — Control Surface UX — director's control room: thumbnails above central monitor, transitions (Cut/Fade/Move), edit mode offline only, left panel two-state (layers/props ↔ chat), right panel eliminated. Verified shipped 2026-08-24.
5. ✅ **TASK 20** — Hotkeys — global keyboard shortcuts. Shipped 2026-08-26: F1-F9 defaults + config UI with modifier chords, persistence, conflict detection, unbinding.
5b. ✅ **Broadcast metadata pull-out + launch geometry** — shipped 2026-08-24 (row 29 above). Live-screen "Text" tab → broadcast form, persistence + remote update; window default/minimums bumped (1920×1040 / 1366×768) with size+position restore.
+5 -4
View File
@@ -17,10 +17,11 @@ may be done opportunistically mid-development — each item has a lock/comment p
real mp4 decode/loop/pacing (TASK 21 picker must have landed), real recording file opens +
plays (fragmented MP4), a real private go-live end-to-end, GUI/RealApp test classes green on
the desktop (they've only ever run against fakes).
5. ☐ **License-expiry reminders** — T-14 / T-7 / T-1 toasts via the existing notification stack
("renews <date> — manage here" → `PremiumUrl`); lapsed: flash returns (the product itself is the
notice) + one Info toast; active paid users see ZERO license UI. Research: does Polar's
validate response carry `expires_at` reliably — cite before building.
5. ⛔ **STRUCK — License-expiry reminders are dead.** This item assumed a Polar license with an
`expires_at` we could warn about. Under **Store IAP** there is no key, no expiry, and no lapse:
the lifetime entitlement never ends and the subscription is renewed by Microsoft. **Nothing to
remind anyone about — do not build.** (The `expires_at` question is moot for the same reason:
`PolarLicenseService` is being deleted.)
6. ☐ **Release engineering** (the `Distribution.md` plan, 644 lines, finally tasked): code signing
(HARD blocker — an unsigned installer is a SmartScreen scarewall), installer + Velopack update URL
(TASK 10 item 8), EULA draft/review, THIRD-PARTY-NOTICES license-texts gate (TASK 4 req 9).
+47 -16
View File
@@ -81,31 +81,62 @@ ONLY paid delta (TASK 36 item 2). Store IAP changes how the bit is *obtained*, n
---
## 1. ⛔ Bundle ffmpeg instead of downloading it — **do this on EVERY route**
## 1. ☑ Bundle ffmpeg instead of downloading it — **SHIPPED 2026-09-28**
**This is item 1 because it is genuinely first** — it fixes a user-facing failure on its own and is a hard certification gate.
`Services/Encoder/FfmpegLocator.cs:37-38` pins a GitHub release URL; `LocateAsync`
downloads (line 69), extracts (line 73), and the encoder executes the result. On a cold
cache the app downloads an unsigned executable from the internet and runs it.
`Services/Encoder/FfmpegLocator.cs` used to pin a GitHub release URL, download it on first run,
extract it, and execute the result. On a cold cache the app downloaded an unsigned executable from
the internet and ran it.
- **Store blocker:** policy 10.2.2 forbids dynamic code inclusion (download-and-execute).
- **Route-independent bug class:** `FfmpegLocator.cs:30-35` records that the previous
daily pin aged out of GitHub retention and **404'd on the first real recording attempt
(2026-09-01)**. Bundling kills this permanently and removes the startup network
dependency.
- **Route-independent bug class:** the previous daily pin aged out of GitHub retention and
**404'd on the first real recording attempt (2026-09-01)**. Bundling kills this permanently and
removes the startup network dependency.
**Do:** ship `ffmpeg.exe` + `ffprobe.exe` + the `libav*.dll` family inside the package;
`FfmpegLocator` resolves PATH → package-local → cache, and never downloads.
`IFfmpegLocator`'s contract, the `ExtractBinaries` staging discipline, and the existing
`FfmpegLocatorTests` cold/cache/PATH coverage all still apply.
**What shipped (2026-09-28, `eb0dd91` docs + this unit):**
- `scripts/fetch-ffmpeg.sh` — populates `./ffmpeg` from a pinned month-end BtbN LGPL-shared
artifact, **SHA-256 verified before extraction**, idempotent, `FFMPEG_ZIP=` override. The
binaries are **gitignored** (135 MB of DLLs would tax every commit on the 9p tree); the pinned
URL + SHA-256 are the audit trail the LGPL written-offer actually needs.
- `ytLive.csproj` — copies `ffmpeg\**` to the output with `CopyToPublishDirectory="Always"` (the
MSIX one), plus a **`GuardBundledFfmpeg` target that fails the build** if the folder is missing,
so a fresh clone finds out before a take rather than during one.
- `FfmpegLocator` — downloader, zip extraction, and the `%APPDATA%\ytLlive\tools` cache are all
**gone**. Order is now **bundle → PATH** (deliberately inverted from "the user's install wins":
a PATH ffmpeg may be a different major version or a GPL build). A missing bundle throws a loud
`IOException` naming the folder and the fetch script — the "found something on disk" path that
could mask a missing bundle is deliberately deleted.
- `ffplay.exe` (18 MB) is **not** shipped; nothing calls it.
- `THIRD-PARTY-NOTICES.txt` — "downloads" → "bundled" throughout, and the stale build tag
`autobuild-2026-08-09-13-03` (**the tag that 404'd**) corrected to `autobuild-2026-08-31-13-27`
with the exact artifact name and archive SHA-256 recorded. OpenH264 item made precise: it is
**compiled into `avcodec-63.dll`**, not a separate binary.
**Immutable tag (licensing rule):** `autobuild-2026-08-31-13-27`, artifact
`ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip`, SHA-256
`46ea6521ed3877689d2ff0bd03640ac2637104415f6237d42625d13408767146`. The URL is now a **provenance
record, not a runtime fetch**. Month-end on purpose: BtbN keeps ~14 dailies but month-end builds
~2 years — a daily pin is what aged out.
**Licensing:** the LGPL-shared build was chosen for LGPL §6 compliance (dynamic linking =
"license text + source offer"). That reasoning is **unaffected**. Confirm
`THIRD-PARTY-NOTICES.txt` covers the bundled build.
"license text + source offer"). That reasoning is **unaffected**, and the notices now cover the
bundled copy explicitly.
**Record the immutable tag** in `TASKS.md` per the licensing rule, and note the URL is now
a provenance record rather than a runtime fetch.
**Tests (16 facts, `FfmpegLocatorTests`, suite 379/379):** the Good Dog integration test walks the
real build output, asserts the 9 files ship, resolves via the locator with an empty PATH, and
**executes `ffmpeg -version`** — running it is the part that matters, because a shared build only
starts if every `libav*.dll` is a sibling. Plus: bundle-over-PATH, PATH fallback, zero-byte bundle,
the loud failure, `TheBuildRefusesToCompileWithoutTheBundle`, and
`TheLocatorCarriesNoNetworkCapability` — a source-level fact that no `HttpClient`/URL/downloader
can reappear, so 10.2.2 cannot rot back.
**Answering "were there other 3p binaries needing bundling?" (verified 2026-09-28): no.** ffmpeg was
the only download-and-execute. `e_sqlite3.dll` + `WebView2Loader.dll` already ship via NuGet into
`runtimes\win-x64\native\`; OpenH264 is inside `avcodec-63.dll`; everything else is managed. The two
real gaps are **not** bundling problems: the **WebView2 Evergreen runtime** is a machine prerequisite
(item 2's manifest dependency + a graceful-failure path), and **Velopack's updater downloads and
executes** — its teardown (item 3) is a compliance action, not just cleanup.
---
+116
View File
@@ -0,0 +1,116 @@
# TASK 50 — One-button "Publish this video" (resumable upload of the local recording)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
> Carved 2026-09-28 from a creator ruling: *"For me, as a creator, I'd love a one-button
> publish this video feature. Who wouldn't?"*
**Status:** ☐ Queued — creator-ruled, not yet scoped in detail
**Goal:** turn the local REC-side MP4 into a channel video with one button, and stop relying on
YouTube's silent automatic archiving to get a recording onto the channel at all.
**Size:** S–M. Media already exists; the work is a resumable-transfer lifecycle. **Not a quarter.**
---
## Why
Two reasons, and the second is the one that actually justifies it.
**1. It fixes a defect the creator already reported as broken.** On 2026-09-26 the creator could
not get private test recordings saved to YouTube, and used the local recording as the proof of
concept instead. The mechanism is now understood and CONFIRMED empirically (creator inspected the
channel 2026-09-28: 20+ private videos, all content-less placeholders). A deliberate upload is
the actual fix; the current behaviour is a side-effect nobody designed.
**2. It is not the heavy feature it looks like.** The instinct is to call upload a big risky
addition because it moves bytes. It doesn't: ffmpeg already moves the media, and the recording
already exists as a finished local MP4. The genuine new work is a **discrete long-running job
with a lifecycle** — resumable, with progress, retry, cancel and quota. RTMP has none of that
because the connection belongs to the encoder process and simply reconnects. Different protocol,
different failure model. That is the whole cost.
**Creator ruling, verbatim:** one button. Not a media manager, not a playlist tool.
---
## Mechanism — CORRECTED 2026-09-28 (creator's channel inspection disproved the earlier model)
Verified against the code AND against the creator's channel:
- `liveBroadcasts.insert` creates a **placeholder video** on the channel for every broadcast the
app makes — that is the source of the 20+ private videos. The creator confirmed 2026-09-28:
a couple dozen+ private videos, all **scheduled placeholders with NO content**.
- Content arrives only if the stream is actually pushed and archived: broadcast reaches **LIVE**
(`liveBroadcasts/transition`), ffmpeg pushes RTMP to the bound stream key, and the broadcast
ends long enough to be transcoded. Content-less placeholders mean **the stream never lands** —
one of those three steps fails in practice.
- `enableAutomaticArchiving` is **never set** by the app, so YouTube's `true` default applies.
- Broadcasts are inserted with `privacyStatus = "private"`
(`Services/YouTubeStreamService.cs:49`), so those orphans are not public. That is correct and
must stay.
- The app has **no delete path** — nothing in `Services/` deletes a broadcast or a video. The
creator clears each orphan by hand in Studio.
**Consequence for the original 2026-09-26 complaint:** it was correct. The private recordings
never saved to YouTube because **no video content was ever produced on the channel** — not
because the app couldn't reach a VOD that existed. The upload pipeline (broadcast → LIVE → RTMP
push → archive) fails; only broadcast creation succeeds.
### The one diagnostic question to answer before this task is scoped
During a real test stream, does ffmpeg actually reach `a.rtmp.youtube.com`, and does the
broadcast reach the LIVE state? If the encoder never starts or the transition never fires, that
is the observed bug — and it is worth a fix BEFORE any publish feature, because this task's
premise ("the recording can get to YouTube") depends on it.
### Sequencing constraint — do not flip the flag early
`enableAutomaticArchiving: false` is the correct end state: the app uploads the local file
deliberately, on demand, instead of YouTube archiving silently. **But automatic archiving is
currently the only route a recording takes to YouTube.** Setting it to `false` before publish
ships would leave test streams with nothing on the channel at all and re-create the 2026-09-26
complaint in a worse form.
**Therefore: ship the upload first, flip the flag as part of the same task, never before.**
---
## Design notes
- **Source is the local MP4**, not the YouTube archive. The app already has it (REC side,
`EncoderOptions.RecordPath`); the archived VOD is a duplicate and an orphan.
- **Resumable upload is mandatory, not optional.** Stream-length recordings are large (hours of
4–8 Mbps video); a plain single POST is not viable. YouTube's `videos.insert` supports
resumable sessions, and the failure mode that matters is a drop at 80%.
- **Default visibility must be `private` or `unlisted`, never `public`.** A bad take going public
is unrecoverable, and this app is used for test streams by definition. The creator picks, and
the default is the safe one.
- **A title still has to come from somewhere.** One button, not zero decisions: default to the
recording's filename, allow an edit, do not block on it.
- **Quota:** `videos.insert` costs 1600 units against a 10,000/day project allowance — roughly
six uploads per day. Fine for a creator, but it is a real ceiling and the app should surface a
quota error rather than retrying into it.
- Scope already permits it: the single `auth/youtube` scope covers upload. No scope change needed.
---
## Open question to settle before coding
**Microsoft Store 11.12 (User-Generated Content).** The standing answer is that the app hosts no
user content and does not interact with third-party UGC — chat is read-only and never persisted,
which is load-bearing for that answer. A publish feature makes the app *push* creator content to
a third-party platform, which touches the same area. It does not obviously break the answer (the
destination is YouTube, which has its own mature moderation, and the app stores nothing), but it
**must be re-checked against
[`research-store-certification.md`](research-store-certification.md) §11.12 and the IARC answers
in [`task-48`](task-48-distribution-msix.md) before submission** — do not let the IARC answers go
to certification describing an app that cannot publish.
---
## Precedence
Belongs to **TASK 48** (MSIX/Store), but does **not** block the Store submission: the app is
fully shippable without it. Recommend MSIX submission first, publish second — unless the creator
reverses that, in which case it moves ahead of the remaining TASK 48 items.
+32 -14
View File
@@ -2,10 +2,14 @@ ytLlive — Third-Party Notices
================================
ytLlive is a paid, closed-source product. This file lists every third-party
component the product distributes or downloads, its license, and where to get
component the product distributes, its license, and where to get
its source, so the LGPL/BSD/MIT obligations are met. Distribution obligations
are NOT optional: they attach because this product ships or automates the
download of these components.
are NOT optional: they attach because this product ships these components.
Note on FFmpeg (items 1-2): it used to be DOWNLOADED at first run. As of
TASK 48 item 1 (2026-09-28) it is BUNDLED — the exact pinned build is fetched
at BUILD time by scripts/fetch-ffmpeg.sh, verified against a SHA-256, and
shipped inside the package. The product fetches no executable at runtime.
If this file changes, update it here AND in the app's About screen (the About
overlay's licensing panel surfaces this file in-app). See TASKS.md (TASK 4) and
@@ -20,34 +24,48 @@ there on purpose.
Home: https://ffmpeg.org/
Source: https://git.ffmpeg.org/ffmpeg.git
Used as: the streaming encoder/RTMP subprocess. This product distributes the
UNMODIFIED binaries; it never links FFmpeg into its own code (it is
launched as a separate process fed raw frames over a pipe).
UNMODIFIED binaries; it never links FFmpeg into its own code (it is
launched as a separate process fed raw frames over a pipe).
Bundled: ffmpeg.exe, ffprobe.exe and the libav*.dll family, unmodified, in a
single `ffmpeg\` folder beside the application executable. They are the
lgpl-SHARED build, so the DLLs must stay siblings of the exe — Windows
resolves them from the executable's own directory.
Why LGPL (not GPL): a GPL build (e.g. gyan.dev, or BtbN's "gpl" variant)
would contaminate this proprietary product. Do NOT use one.
would contaminate this proprietary product. Do NOT use one.
Why "shared" (not static): LGPL v2.1 §6 requires "relinkable" materials for
statically-linked libraries. The shared build links dynamically, so
the user can replace the DLLs — compliance is this notice plus the
source offer below, with no relink material required.
statically-linked libraries. The shared build links dynamically, so
the user can replace the DLLs — compliance is this notice plus the
source offer below, with no relink material required.
Compliance supplied by this product:
- The license text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
- The corresponding source / written offer to obtain it:
FFmpeg source https://ffmpeg.org/download.html
Exact binary https://github.com/BtbN/FFmpeg-Builds
Build tag: autobuild-2026-08-09-13-03 (variant lgpl-shared)
Exact artifact ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip
Build tag: autobuild-2026-08-31-13-27 (variant lgpl-shared)
Archive SHA-256: 46ea6521ed3877689d2ff0bd03640ac2637104415f6237d42625d13408767146
- The binaries are unmodified and the LGPL notices therein are intact.
- The SHA-256 above is the integrity record for the bundled copy; it is
asserted by scripts/fetch-ffmpeg.sh before extraction and re-derivable
from the artifact URL in that script.
2. BtbN FFmpeg-Builds (the exact binary this product downloads)
2. BtbN FFmpeg-Builds (the build system that produced the bundled binary)
License: MIT (build scripts + repository) — the produced binaries are
covered by FFmpeg's LGPL (item 1).
Home: https://github.com/BtbN/FFmpeg-Builds
3. OpenH264 (libopenh264 — the H.264 software fallback encoder)
3. OpenH264 (the H.264 software fallback encoder, libopenh264)
Copyright (c) 2010-2026 Cisco Systems, Inc. (and contributors)
License: BSD 2-Clause + Cisco's H.264 patent grant
Home: https://www.openh264.org/
Note: Cisco grants the patent license for its own H.264 implementation;
it ships inside the FFmpeg build above (LGPL obligations of item 1
apply to the library; the BSD terms apply to Cisco's code).
it is COMPILED INTO avcodec-63.dll in the bundled build above (verified
2026-09-28: the encoder is registered in that DLL; there is no separate
openh264 binary to ship). So item 1's LGPL obligations apply to the FFmpeg
code and Cisco's BSD terms plus its patent grant apply to the OpenH264
code inside it. This is the product's software H.264 fallback when no
hardware encoder (NVENC/QSV/AMF) is available — it is deliberately never
libx264, which would be GPL.
4. SQLite (bundled via SQLitePCLRaw's e_sqlite3 native bundle)
License: public domain (no rights reserved)
+13 -1
View File
@@ -126,9 +126,21 @@ public partial class MainViewModel
await Task.CompletedTask;
}
/// <summary>Change account: sign in to a different YouTube account WITHOUT
/// destroying the current session first.
/// <para><b>The bug this fixes (2026-09-27).</b> This used to be
/// <c>await SignOutYouTubeAsync(); await SignInAsync();</c> — it signed out
/// <i>before</i> opening the browser, and <c>SignOutYouTubeAsync</c> calls
/// <c>TokenStore.Clear()</c>, which DELETES the saved token file. The creator
/// clicked "Change Account", cancelled in the Google account chooser, and came
/// back to an app that had already thrown away a working, signed-in account.
/// </para>
/// <para>No sign-out is needed at all: on success <see cref="SignInAsync"/>
/// replaces the session and the <c>sessionChanged</c> hook overwrites the token
/// file with the new account. On cancel or failure it returns null and leaves
/// the current session untouched — which is the behaviour that was wanted.</para></summary>
private async Task ChangeAccountAsync()
{
await SignOutYouTubeAsync();
await SignInAsync();
}
}
+64 -12
View File
@@ -15,6 +15,15 @@ public partial class MainViewModel
private readonly MusicPlayer _musicPlayer = new();
private Music? _music;
private bool _musicPlaying;
private bool _traxLoadInFlight;
/// <summary>Hard ceiling on the TRAX reachability probe. A track on an offline
/// network share blocks ~40s inside <c>MediaFoundationReader</c> before it throws
/// 0x80070035, and that call sat on the startup path inside LoadLayout, so the
/// window froze for the whole SMB timeout. The probe runs off the UI thread and is
/// abandoned at this budget; the orphan thread stays parked until SMB gives up,
/// which is the point — it is not the thread the creator is waiting on.</summary>
internal static readonly TimeSpan TraxProbeBudget = TimeSpan.FromSeconds(2);
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
/// <summary>True while the TRAX background track is playing.</summary>
@@ -77,27 +86,70 @@ public partial class MainViewModel
LoadTrax(play: true);
}
private void LoadTrax(bool play)
/// <summary>Probes a path off the UI thread and gives up at <paramref name="budget"/>.
/// A probe may time out — that is the whole point. False means offline, missing, or
/// slower than the budget, and every one of those is a normal state, not an error.</summary>
internal static async Task<bool> IsTrackReachableAsync(string path, TimeSpan budget, Func<string, bool> probe)
{
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
var probeTask = Task.Run(() =>
{
try
{
return probe(path);
}
catch
{
return false;
}
});
var finished = await Task.WhenAny(probeTask, Task.Delay(budget)).ConfigureAwait(false);
return finished == probeTask && await probeTask.ConfigureAwait(false);
}
private void LoadTrax(bool play) => _ = LoadTraxAsync(play);
private async Task LoadTraxAsync(bool play)
{
var music = _music;
if (music == null || string.IsNullOrWhiteSpace(music.TrackPath) || _traxLoadInFlight)
return;
_traxLoadInFlight = true;
var loaded = false;
try
{
_musicPlayer.Load(_music.TrackPath);
UpdateTraxLocalGain();
_music.IsEnabled = true;
TraxError = null;
if (play)
_musicPlayer.Play();
var path = music.TrackPath;
if (await IsTrackReachableAsync(path, TraxProbeBudget, File.Exists))
{
// Reached the share, so this bind is cheap and stays on the UI thread
// with every other MusicPlayer mutation — no new race to reason about.
_musicPlayer.Load(path);
UpdateTraxLocalGain();
music.IsEnabled = true;
TraxError = null;
if (play)
_musicPlayer.Play();
loaded = true;
}
else
{
AppLog.Write($"TRAX: '{path}' unreachable within {TraxProbeBudget.TotalSeconds:0}s — not loading");
TraxError = $"Can't reach {Path.GetFileName(path)} — is the drive on?";
}
}
catch (Exception ex)
{
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}";
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
TraxError = $"Failed to load: {Path.GetFileName(music.TrackPath)} — {ex.Message}";
}
finally
{
_traxLoadInFlight = false;
RefreshTraxUi();
if (loaded)
ScheduleSave();
}
RefreshTraxUi();
ScheduleSave();
}
private void PlayTrax() => _musicPlayer.Play();
+119 -52
View File
@@ -41,7 +41,7 @@ All LAN credentials live in `CREDENTIALS.md` (gitignored). Server IPs and roles:
| Host | IP | Role |
|------|----|------|
| corsair | .132 | Gaming/streaming primary — RTX 5070 Ti (this machine) |
| llamavault | .86 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: `ssh mshallop@192.168.50.86` |
| llamavault | .87 | ODROID-HC4, 12TB RAID5, media storage (NFS). SSH: `ssh mshallop@192.168.50.87`. **DHCP-bound — it moved off `.86` after the 2026-10 power outage, so confirm with a ping before trusting any path.** SMB is reached by the **`llamavault` name**, pinned in the Windows hosts file, never by literal IP: `\\llamavault\llamaVault\...` |
| pivault | .158 | RPi5 8GB, 10.83TB RAID5 NAS |
| jarvis | .210 | AMD Ryzen 5 5600X, RX 6600 XT — AI inference, SearXNG, Docker registry, Immich |
| ultron | .108 | AMD Ryzen 7 7840HS — AI orchestrator, llama-server, Qdrant |
@@ -113,6 +113,18 @@ collaboration, not autonomous code generation.
## Run
**First time on a fresh clone (or if `ffmpeg/` was deleted) — this is REQUIRED, not optional:**
```bash
./scripts/fetch-ffmpeg.sh # ~65 MB once; verifies SHA-256, extracts 9 files into ./ffmpeg
```
FFmpeg is **bundled, never downloaded at runtime** (Windows Store policy 10.2.2 — the old runtime
download 404'd on 2026-09-01 and would fail certification). The binaries are gitignored because
135 MB of DLLs in git would tax every commit on this 9p tree; the pinned URL + SHA-256 in the script
are the audit trail. `ytLive.csproj`'s `GuardBundledFfmpeg` target **fails the build** with that
exact instruction if the folder is missing. `FFMPEG_ZIP=<path>` reuses a pre-downloaded archive.
```bash
# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
@@ -229,7 +241,8 @@ The AI hallucinated through multiple commits that night on background/scene prop
### Current limitations / TODOs
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). Sign-out is **explicit only** (Logout / Change Account → `SignOutYouTubeAsync`): `StopStream()` does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). Sign-out is **explicit only**, and that means **Logout — NOT Change Account**: `StopStream()` does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` + `openBrowser` callback (test seams + save hook; services are still constructed in `MainViewModel`). **Change Account must never sign out first** (fixed 2026-09-27): it used to `await SignOutYouTubeAsync(); await SignInAsync();`, and since `SignOutYouTubeAsync` → `TokenStore.Clear()` **deletes** `ytLlive.auth`, cancelling in the Google chooser left the creator logged out of a working account. It is now just `await SignInAsync()` — success overwrites the token via the `sessionChanged` hook, cancel/failure leaves the session untouched. The OAuth flow also **releases its loopback callback port on retry**: abandoning the browser leaves the attempt holding `localhost:8765` for its 5-minute timeout, so a new `AuthenticateAsync` cancels and awaits the abandoned attempt before binding, serialised by a `SemaphoreSlim` (`_authGate`) — without that gate, two overlapping calls both see a free port and race to bind it (the exact failure the takeover prevents)
- **Google Cloud project ownership (recorded 2026-09-27, do not re-hunt):** OAuth ClientId is `198374416003-s8tm3rb5csh91quvlg6mnec6srmrr6md.apps.googleusercontent.com`, so the **Cloud project number is `198374416003`**. That project is owned by the **`llamachile.shop@gmail.com`** ("gramps") Google account. ⛔ **This is NOT the same account as the YouTube channel a creator signs into the app with** — console access and channel identity are separate logins, and assuming they are the same was the trap that stalled the search. Direct link that resolves it in one hop: `https://console.cloud.google.com/apis/credentials?project=198374416003`. If the picker shows no match, it defaults to the **"Recent"** tab — switch to **"All"** before concluding the project is missing.
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table — `LicenseKey`/`LicenseValidatedAt`/`IsPremium`, 14-day offline grace)
- `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; the real API nests them under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the parse accepts that object shape and the legacy flat-string shape (fix 2026-09-22: the object shape crashed every poll with `requires an element of type 'String'`); banner decision in pure `StreamHealthReporter`
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED**, **frame-pipeline wiring (TASK 4 ship step 5) is SHIPPED**, **health stats (TASK 4 ship step 6) is SHIPPED**, **one-click go-live + private-only enforcement (TASK 4 ship step 7) is SHIPPED** — TASK 4 (RTMP Ingest) is fully done; full plan in `TASKS.md`
@@ -618,37 +631,58 @@ stays XAML (editing view); the compositor is the output view.
resolver in the encoder step, not the compositor step. The master buffer (the compositor's return
value) is the seam a future D3D11 compositor would honor identically.
### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10, plan in TASKS.md)
### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10; **rewritten to BUNDLED 2026-09-28**, plan in TASKS.md)
The encoder's one external dependency is `ffmpeg.exe`; it's never shipped in the repo. `IFfmpegLocator`
resolves an absolute path on demand: **PATH probe first** (the user's own install wins — their choice,
their responsibility), then the cache (`%APPDATA%\ytLlive\tools\ffmpeg.exe`), then a **pinned** BtbN
LGPL-**shared** win64 zip (~75 MB) from which `ffmpeg.exe` **and the `libav*.dll` family** are extracted
(staged temp-write + move so a crash never corrupts the cache; Windows resolves the DLLs from the exe's
own directory). BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
The encoder's one external dependency is `ffmpeg.exe`. **It is BUNDLED — never downloaded, never
fetched at runtime.** `IFfmpegLocator` resolves an absolute path on demand: **the bundle first**
(`<AppContext.BaseDirectory>\ffmpeg\ffmpeg.exe`), then **PATH** as the escape hatch for someone who
deliberately installed their own. The order is the opposite of the original ("the user's own install
wins") on purpose: a PATH `ffmpeg` may be a different major version or a **GPL** build, and the
bundle is the one that was pinned, license-vetted and tested. If neither hits, it throws a loud
`IOException` naming the missing folder and the fetch script — the old "found something on disk"
cache path is gone deliberately, because silently satisfying a missing bundle with a stale copy is
exactly the silent drift a certification-gated dependency must not have.
**The bundle is not in git** (135 MB of incompressible DLLs would tax every commit on this
9p-bridged tree). `scripts/fetch-ffmpeg.sh` populates `./ffmpeg` from a pinned month-end BtbN
LGPL-**shared** win64 artifact, **verifies SHA-256 before extracting**, and is idempotent; set
`FFMPEG_ZIP=<path>` to reuse a pre-downloaded archive. `ytLive.csproj` copies `ffmpeg\**` to the
output (`CopyToPublishDirectory="Always"` for the MSIX package) and the `GuardBundledFfmpeg` target
**fails the build** if the folder is absent, so a fresh clone finds out before the creator presses
Record rather than during a take. `ffmpeg.exe` + `ffprobe.exe` + the `libav*.dll` family must stay in
one folder — Windows resolves a shared build's DLLs from the executable's own directory.
`ffplay.exe` (18 MB) is deliberately **not** shipped; nothing calls it.
BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
source offer" with no static-relink (§6) material — see the Licensing guardrails below. The pin is a
dated autobuild tag (immutable); BtbN retention keeps the last 14 daily + each month-end for 2 years, so
a cold cache can outlive the pin → the seam throws a clear, logged error (recoverable; the pin is one
const). Constructor-injected search dirs / tools dir / downloader (`Func<string, CancellationToken,
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
encoder step (not yet — this PR ships the seam + impl + tests only).
source offer" with no static-relink (§6) material — see the Licensing guardrails below. libopenh264
is **compiled into `avcodec-63.dll`** (verified 2026-09-28: the encoder is registered there, and there
is no separate OpenH264 binary to ship), so Cisco's BSD terms + patent grant ride along inside item 1
of `THIRD-PARTY-NOTICES.txt`. Pin: `autobuild-2026-08-31-13-27` (month-end on purpose — BtbN keeps
~14 dailies but month-end builds ~2 years; a daily pin is what 404'd, see below).
**⛔ The pinned-download design is now formally a defect, not a cold-start edge case (2026-09-27).**
It **failed in production on 2026-09-01** — the previous daily pin aged out of BtbN's 14-day
retention and the download 404'd on the creator's first real recording attempt. Two facts make
the "pin is a const, just bump it" assumption wrong:
**⛔ Why this was rewritten (2026-09-27 → shipped 2026-09-28).** The pinned-download design was a
defect, not a cold-start edge case. It **failed in production on 2026-09-01** — the previous daily
pin aged out of BtbN's 14-day retention and the download 404'd on the creator's first real recording
attempt. Two facts made "the pin is a const, just bump it" wrong:
- **Microsoft Store policy 10.2.2 forbids dynamic code inclusion.** Downloading an executable
from the internet and running it is the exact pattern the policy names. An MSIX submission
is rejected over it.
- Bumping the pin is whack-a-mole against a retention window we do not control.
**Fix (TASK 48 item 1, recommended on EVERY route — it is not Store-conditional):** bundle
`ffmpeg.exe` + `ffprobe.exe` + the `libav*.dll` family **inside the package**; `FfmpegLocator`
resolves PATH → package-local → cache and never downloads. This also deletes the startup
network dependency entirely. The LGPL-**shared** build choice is **unaffected** — dynamic
linking was chosen for LGPL §6 compliance ("license text + source offer"), not to enable
downloading. Analysis: `TASKS/research-store-certification.md` §4.
Bundling deletes the startup network dependency entirely. The LGPL-**shared** build choice is
**unaffected** — dynamic linking was chosen for LGPL §6 compliance ("license text + source offer"),
not to enable downloading. Analysis: `TASKS/research-store-certification.md` §4.
**Guardrail:** `FfmpegLocatorTests.TheLocatorCarriesNoNetworkCapability` asserts the locator's
source contains no `HttpClient`/URL/downloader at all, so the policy cannot rot back.
**Every other third-party binary already ships via NuGet — ffmpeg was the only download-and-execute.**
Verified 2026-09-28: `e_sqlite3.dll` and `WebView2Loader.dll` land in `runtimes\win-x64\native\`
(SQLitePCLRaw + WebView2 packages) and NAudio/Notification.Wpf/Microsoft.Data.Sqlite/ProtectedData
are managed. Two gaps remain and are **not** fixed by bundling: the **WebView2 Evergreen runtime** is
a machine prerequisite (a manifest framework-dependency + graceful-failure path, TASK 48 item 2), and
**Velopack's updater downloads and executes** — its teardown is a compliance action, not just cleanup.
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
@@ -717,10 +751,14 @@ lights when a session is actually running.
gold/red/green). The account **status dot is gone** — the avatar/Sign In pair carries connectedness.
`ShowPrimaryStartButton` remains the always-idle face. `StopStream` clears both pills (see "Stop ends
everything; failures roll back" below).
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty-<yyyyMMdd>-<HHmm start>-0000.mp4` at start;
**rename-on-stop** to `ty-…-<hh2mm2 actual length>.mp4` (`FinalizeRecordingAsync` after the pump stops &
the file closes), numeric `-2`/`-3` suffix on collision (`UniquePath`). Length comes from `_liveElapsed`,
which the session timer walks while `IsLive || IsRecording`.
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty-<yyyyMMdd>-<HHmm start>-<fff>-0000.mp4` at
start; **rename-on-stop** to `ty-…-<hh2mm2 actual length>.mp4` (`FinalizeRecordingAsync` after the pump
stops & the file closes), numeric `-2`/`-3` suffix on collision (`UniquePath`). Length comes from
`_liveElapsed`, which the session timer walks while `IsLive || IsRecording`. The **`<fff>` millisecond
segment is load-bearing, not decoration** (2026-09-27): both names derive from `_recordStartTime`, so it
survives the rename, and without it two instances started in the same minute compute the *same* start
path and both ffmpeg processes write one file — which is exactly the dev multi-instance case
(`Helpers/InstanceProfile`, one instance recording the other).
- **Save dialog: Cancel DISCARDS (creator ruling 2026-09-26).** The `RenameRecordingDialog` result is a
two-outcome decision, and it lives in the extracted `MainViewModel.CompleteRecordingSave(startPath, dir,
autoStem, chosenStem, creatorSaved)` (internal, so `ytLive.Tests` can drive it against real files without
@@ -737,8 +775,10 @@ lights when a session is actually running.
branch handled explicitly — a falsy result must never fall through into the effect.**
- **Folder:** default `%APPDATA%\ytLlive\recordings\`, user-overridable via `ChooseRecordFolderCommand`
(`OpenFolderDialog`), persisted through `LayoutStore.Load/SaveRecordFolder` (`RecordFolder` key).
- **Explicit sign-out only:** `StopStream` no longer clears the session/token. Sign out via Logout /
Change Account. Stopping a recording leaves the creator signed in.
- **Explicit sign-out only:** `StopStream` no longer clears the session/token. Sign out via **Logout
only** — Change Account re-authenticates *in place* and deliberately does not sign out first
(2026-09-27; signing out first deleted `ytLlive.auth` before the browser even opened, so a
cancelled switch lost a working session). Stopping a recording leaves the creator signed in.
- **Stop ends everything; failures roll back (2026-09-01):** `StopStream` also clears both intent
pills (`RecordPillOn`/`OnAirPillOn`) — a lit pill with no session behind it is a lie. Every
frame-pump death and every go-live prep failure now runs the full `StopStream` teardown instead of
@@ -831,6 +871,22 @@ devices, no timers).
track persists via **schema v9** single-row `Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known
wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on
live, off VOD" is tabled.
- **⛔ The load is behind a bounded probe, because a share that is gone must never
freeze startup (2026-10-04).** `LoadLayout` → `LoadTrax` used to call `MusicPlayer.Load`
synchronously on the UI thread, and `new MediaFoundationReader(path)` does **not** fail
fast on a dead SMB session — it burns the whole ARP/TCP/SMB timeout (~40s, measured)
and *then* throws `0x80070035`. Measured: `OnStartup` 08:42:59 → `MainWindow loaded`
08:43:43, with 42 of those 44s inside `LoadLayout` on that one track. The rule now:
**a probe may time out; only a bind is allowed to be on the creator's path.**
`IsTrackReachableAsync(path, budget, File.Exists)` runs the probe on a thread-pool
thread under a `Task.WhenAny` ceiling (`TraxProbeBudget` = **2s**) and reports
offline/missing/slow as a normal state, not an error. Only a probe that passed gets
the real bind, and **that bind stays on the UI thread** with every other
`MusicPlayer` mutation, so no new cross-thread race is introduced. The orphan probe
thread stays parked until SMB gives up — deliberate: it is not the thread anyone
waits on. Same principle covers the webcam: a device held by another app fails fast
(sharing violation) but a virtual-camera driver can stall enumeration, so device
probes belong off the startup path too, not behind a long timeout.
- **Mic status dot (`Models/MicStatus.cs`)** on the preview-bottom MIC button: green = `MicConnected`,
yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started,
so loopback and the game bar can't run either — no capture devices at all). **The picked mic persists:
@@ -1466,7 +1522,8 @@ Apply this to every UI decision:
## Monetization (design decision — the branding flash is the sword)
Full pricing, discount codes, and Polar product details in `MONETIZATION.md` (gitignored).
Full pricing and the Year 1 revenue forecast in `MONETIZATION.md` (gitignored). ⚠️ **No Polar
product, discount code, or license key exists any more** — billing is Microsoft Store IAP.
Brand palette and assets in `MARCOM.md` (gitignored). This section covers the in-app model.
Free forever: **all features unlocked for everyone** — no feature lock between free and paid.
@@ -1514,30 +1571,40 @@ crooked.
credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single
5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer
(the `Step` helpers in `BrandFlashOutputTests`).
- **Paid (one-time perpetual license):** branding flash removed. `BrandFlashEnabled` is now a
**derived, non-assignable** `!IsPremium` and the presenter's own `Enabled` gate is re-checked on
every frame, so a key entered (or revoked) mid-credit cuts the advertisement on the next tick
rather than letting it finish. That's it. No feature gating. Alerts, social bar slots, voice
filters, TRAX, recording — everything is free.
- **Pricing (2026-09-21 — switched from subscription to one-time "own it"):** **$29 lifetime** founder's
price (launch → 90 days) → **$49 lifetime** list at GA. The key is **perpetual** (`IsPremium` never
lapses; the old renewal/lapse path is dead). Rationale: a local app with no per-user server cost and a
renewal-averse, free-surrounded audience — minimize commitment size, don't charge rent. Accessibility is
the driver: the free tier is the *full* app (only the watermark differs), so a broke new streamer pays $0;
the low one-time price is the "no recurring bill" entry. Full reasoning, and the dropped $49.99/yr →
$99/yr subscription model, in `MONETIZATION.md`.
- **Billing:** **Polar (polar.sh)** — open-source MoR (Apache 2.0), handles payments, **one-time orders**,
license keys, and global tax compliance. Startup Program gives Scale plan free for 12 months.
Product: `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (⚠️ currently a $99/yr *subscription* product — must be
re-created as a one-time product for the new model). Checkout: `llamacasty.com` → Polar hosted page.
Org ID: `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` omitted from API calls).
Key prefix: `LCYT-`. Discounts: `LLAMAFOUNDER` (100% off, 50 uses), `LLAMA50` (50% off, 12 months). Details in `MONETIZATION.md`.
- **Paid:** branding flash removed. `BrandFlashEnabled` is now a **derived, non-assignable**
`!IsPremium` and the presenter's own `Enabled` gate is re-checked on every frame, so the
entitlement changing mid-credit cuts the advertisement on the next tick rather than letting it
finish. That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording —
everything is free. **How `IsPremium` is obtained is a billing concern and has changed twice;
what it gates has not.**
- **Pricing (✅ DECIDED 2026-09-27 — supersedes the 2026-09-21 one-time "own it" model):**
**`$10/mo` subscription, or `$400` lifetime. No annual. No `.99` prices.** ⛔ The old `$29`
founder → `$49` list lifetime ruling is **dead — do not cite it.** Two tiers, one delta: they
break even at **month 40**, so they barely compete, which is why lifetime is safe to offer at
launch instead of saving it for later. The free tier is the *full* app (only the watermark
differs), so accessibility is preserved without pricing down. ⛔ No annual tier: Microsoft does
not pro-rate and **the developer cannot refund** the forfeited remainder — verbatim the
grievance that drove the ruling. **ADA §6(h): raising the price disables auto-renew, so `$10` is
effectively locked for the product's life.** Full reasoning in `MONETIZATION.md` → Pricing.
- **Billing: ⛔ Polar is DELETED — Microsoft Store IAP, Microsoft as merchant of record** (ruling
2026-09-27; teardown in `TASKS/task-48-distribution-msix.md` item 0). **Revenue share 15%,
subscriptions included** — verified from the ADA v8.10 PDF, §6(b)(i); the agreement's own v8.0
changelog says *"85/15 revenue share for non-Game subscriptions"*. ⇒ net **~$8.50/mo**,
**~$340** lifetime. Microsoft handles VAT/GST/sales tax, refunds, chargebacks, the customer
portal, and device deactivation — the strongest argument for the switch. Net receipts are after
tax, so commission is not paid on pass-through tax. Payouts monthly, $50 threshold.
⚠️ **No Polar product, org ID, key prefix, or discount code exists any more** — `d105dfa1-…`,
`c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/`LLAMA50`, the customer-portal validate endpoint, and the
14-day offline-grace machine are all being deleted. Products are declared in **Partner Center**
instead: one auto-renewing monthly subscription + one non-expiring lifetime IAP.
⛔ A discounted subscriber→lifetime upgrade SKU is **not** approved.
- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the
once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are
how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with
emergency patches; not a 24/7 service promise. The only license chatter is the paid-user **expiry
reminder — now moot** (perpetual keys never expire, one-time model 2026-09-21); paid users see zero
license UI. TASK 36 item 5 (expiry reminder) is superseded.
reminder — now moot** (superseded twice: keys are moot because the lifetime entitlement never
expires, and the entire license-key system is moot because Store IAP has no keys); paid users see
zero license UI. TASK 36 item 5 (expiry reminder) is struck.
**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# fetch-ffmpeg.sh — populate ./ffmpeg/ with the pinned BtbN LGPL-shared FFmpeg build.
#
# WHY THIS EXISTS: the app used to download ffmpeg from GitHub on first run and execute
# it. That is Windows Store policy 10.2.2 (dynamic code inclusion) and it already cost a
# real failure — the pin aged out of BtbN's 14-day retention and the download 404'd on
# the creator's first recording attempt (2026-09-01). FFmpeg is now BUNDLED, so nothing is
# fetched at runtime. This script is the BUILD-TIME half of that: a fresh clone has no
# ffmpeg/ folder, so it must be filled once before the first build.
#
# The binaries are NOT committed to git (135 MB of incompressible DLLs would tax every
# commit on this 9p-bridged working tree). The URL + SHA-256 below are the audit trail
# that the LGPL written-offer obligation actually needs, and they are reviewable in a diff.
#
# Usage: ./scripts/fetch-ffmpeg.sh
# FFMPEG_ZIP=/path/to/already-downloaded.zip ./scripts/fetch-ffmpeg.sh
#
# Idempotent: re-running with ffmpeg/ already populated verifies and exits. Delete
# ffmpeg/ to force a refetch.
set -euo pipefail
# Pinned BtbN lgpl-shared win64 build. A MONTH-END autobuild tag on purpose: BtbN keeps
# ~14 dailies, but month-end builds for ~2 years. Do not "refresh" this to a daily — that
# is the mistake that caused the 404.
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip"
SHA256="46ea6521ed3877689d2ff0bd03640ac2637104415f6237d42625d13408767146"
# The files we ship. ffplay.exe (18 MB) is deliberately excluded — nothing in the app
# calls it, and shipping an unused player would widen the package and the license surface
# for nothing.
WANT_EXE="ffmpeg.exe ffprobe.exe"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$ROOT/ffmpeg"
if [ -f "$DEST/ffmpeg.exe" ]; then
echo "✓ ffmpeg/ already populated — nothing to do."
echo " (delete $DEST to force a refetch)"
exit 0
fi
command -v python3 >/dev/null 2>&1 || {
echo "❌ python3 is required to extract the zip (unzip is not installed in this WSL)."
echo " Set FFMPEG_ZIP to a pre-downloaded archive, or install python3."
exit 1
}
ZIP="${FFMPEG_ZIP:-}"
if [ -z "$ZIP" ]; then
ZIP="$(mktemp -t ffmpeg-pin-XXXXXX.zip)"
trap 'rm -f "$ZIP"' EXIT
echo "══ Downloading the pinned FFmpeg build (~65 MB, one time) ══"
curl -fsSL --retry 3 -o "$ZIP" "$URL"
fi
echo "══ Verifying SHA-256 ══"
GOT="$(sha256sum "$ZIP" | cut -d' ' -f1)"
if [ "$GOT" != "$SHA256" ]; then
echo "❌ SHA-256 mismatch — refusing to extract."
echo " expected $SHA256"
echo " actual $GOT"
exit 1
fi
echo "✓ $GOT"
echo "══ Extracting into ffmpeg/ ══"
python3 - "$ZIP" "$DEST" "$WANT_EXE" <<'PY'
import os, sys, zipfile
archive_path, dest, want = sys.argv[1], sys.argv[2], set(sys.argv[3].split())
wanted_exe = {n for n in want}
os.makedirs(dest, exist_ok=True)
found = []
with zipfile.ZipFile(archive_path) as z:
for name in z.namelist():
if '/bin/' not in name or name.endswith('/'):
continue
base = os.path.basename(name)
if not (base.endswith('.dll') or base in wanted_exe):
continue
with z.open(name) as src, open(os.path.join(dest, base), 'wb') as out:
out.write(src.read())
found.append(base)
missing = wanted_exe - set(found)
if missing:
sys.exit(f"❌ archive is missing required binaries: {sorted(missing)}")
print(f"✓ extracted {len(found)} files")
for base in sorted(found):
print(f" {base}")
PY
echo ""
echo "✓ Bundled FFmpeg ready. The build copies ffmpeg/ → bin/<config>/<tfm>/ffmpeg/."
echo " Nothing is downloaded at runtime any more (Store policy 10.2.2 satisfied)."
+203 -161
View File
@@ -1,39 +1,30 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using Xunit;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The FFmpeg locator (TASK 4 ship step 2): resolves ffmpeg.exe by probing PATH
/// first, then the cache in the tools directory, then pulling the pinned BtbN
/// LGPL-shared zip. The integration test drives the full ladder against a temp
/// tools dir and a fake downloader that returns a real in-memory zip; the units
/// pin down the failure and edge cases. No network, no real binary.
/// The FFmpeg locator (TASK 4 ship step 2, rewritten by TASK 48 item 1).
///
/// <para><b>What changed.</b> The locator used to pin a BtbN release URL, download the zip
/// on first run, extract it, and execute it. That is Windows Store policy 10.2.2 (dynamic
/// code inclusion — <c>TASKS/research-store-certification.md</c> §4) and it had already
/// failed in production: the pin aged out of BtbN's 14-day retention and the download
/// <b>404'd on the creator's first real recording attempt</b> (2026-09-01). FFmpeg is now
/// <b>bundled</b>, fetched at build time by <c>scripts/fetch-ffmpeg.sh</c> and copied into
/// <c>ffmpeg/</c> beside the executable. Nothing is downloaded at runtime, ever.</para>
///
/// <para><b>Why the order flipped.</b> PATH used to win ("the user's own install wins").
/// With a bundle in the package that is backwards: a PATH <c>ffmpeg</c> may be a different
/// major version or a GPL build, and the bundle is the one that was pinned, license-vetted
/// and tested. PATH remains the escape hatch for someone who deliberately wants their own.</para>
/// </summary>
public class FfmpegLocatorTests
{
private static byte[] MakeZip(
string exePath = "ffmpeg-master-latest-win64-lgpl-shared/bin/ffmpeg.exe",
string[]? dllPaths = null)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(exePath);
using (var writer = new StreamWriter(entry.Open()))
writer.Write("dummy ffmpeg binary");
foreach (var dll in dllPaths ?? Array.Empty<string>())
{
var dllEntry = archive.CreateEntry(dll);
using var dllWriter = new StreamWriter(dllEntry.Open());
dllWriter.Write("dummy dll");
}
}
return ms.ToArray();
}
private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
@@ -41,146 +32,197 @@ public class FfmpegLocatorTests
return dir;
}
private sealed class RecordingDownloader
{
public int Calls { get; private set; }
public byte[] Payload { get; set; } = MakeZip();
public Exception? Error { get; set; }
public Task<byte[]> DownloadAsync(string url, CancellationToken ct)
{
Calls++;
if (Error != null) throw Error;
return Task.FromResult(Payload);
}
}
/// <summary>
/// The Good Dog integration test for this unit: prove the whole chain end to end —
/// the build actually copied the bundle into the app's output, the locator resolves it
/// without any network, and the located binary <b>executes</b>. Running it is the part
/// that matters most: FFmpeg is an lgpl-<i>shared</i> build, so it only starts if every
/// <c>libav*.dll</c> sits beside the exe. A packaging change that dropped the DLLs would
/// pass every existence check and still hand the user a broken encoder.
/// </summary>
[Fact]
public async Task Locate_Integration_FullDecisionLadder()
public async Task Locate_Integration_TheBuiltOutputShipsABundleThatRuns()
{
var toolsDir = TempDir();
try
// NOTE: the output root is derived from the *project* directory, not from the
// repository folder name. The repo directory happens to also be called "ytLive",
// so naively appending "ytLive" would resolve to a path that only works on this
// machine's checkout.
var projectDir = Path.GetDirectoryName(SourceAssert.RepoPath("ytLive.csproj"))!;
var binRoot = Path.Combine(projectDir, "bin");
Assert.True(Directory.Exists(binRoot),
$"no build output at {binRoot} — build ytLive.csproj before running this test");
var withBundle = Directory
.GetDirectories(binRoot, "*", SearchOption.AllDirectories)
.Where(d => File.Exists(Path.Combine(d, FfmpegLocator.BundleFolderName, FfmpegLocator.FileName)))
.ToArray();
Assert.True(withBundle.Length > 0,
$"No build output under {binRoot} carries ffmpeg/{FfmpegLocator.FileName}. " +
"Run ./scripts/fetch-ffmpeg.sh, then rebuild — the csproj GuardBundledFfmpeg " +
"target should have failed the build if the source folder was missing.");
var outputDir = withBundle[0];
var bundleDir = Path.Combine(outputDir, FfmpegLocator.BundleFolderName);
// The sibling DLLs are the shared-build's whole dependency story.
foreach (var required in new[] { FfmpegLocator.ProbeFileName, "avcodec-63.dll", "avformat-63.dll" })
Assert.True(File.Exists(Path.Combine(bundleDir, required)),
$"{required} must ship beside {FfmpegLocator.FileName} — a shared build resolves DLLs " +
"from the executable's own directory.");
// The locator resolves it with an empty PATH, i.e. nothing outside the bundle.
var resolved = await new FfmpegLocator(searchDirs: [], bundleRoot: outputDir).LocateAsync();
Assert.Equal(Path.Combine(bundleDir, FfmpegLocator.FileName), resolved);
// And it actually runs. This is what proves the DLLs resolve.
var info = new ProcessStartInfo
{
// 1. PATH hit wins, downloader never invoked.
var pathDir = TempDir();
var pathExe = Path.Combine(pathDir, FfmpegLocator.FileName);
File.WriteAllText(pathExe, "user's ffmpeg");
var downloader = new RecordingDownloader();
var fromPath = new FfmpegLocator([pathDir], toolsDir, downloader.DownloadAsync);
Assert.Equal(pathExe, await fromPath.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 2. Cache hit skips the network.
var cached = Path.Combine(toolsDir, FfmpegLocator.FileName);
Directory.CreateDirectory(toolsDir);
File.WriteAllText(cached, "cached ffmpeg");
var fromCache = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
Assert.Equal(cached, await fromCache.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 3. Cold cache downloads exactly once, extracts ffmpeg.exe, and the
// second call serves the cache without re-downloading.
File.Delete(cached);
var coldTools = TempDir();
var cold = new FfmpegLocator([], coldTools, downloader.DownloadAsync);
var resolved = await cold.LocateAsync();
Assert.Equal(Path.Combine(coldTools, FfmpegLocator.FileName), resolved);
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
Assert.Equal(resolved, await cold.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_ZeroByteCache_IsRefreshed()
{
var toolsDir = TempDir();
try
{
File.WriteAllText(Path.Combine(toolsDir, FfmpegLocator.FileName), "");
var downloader = new RecordingDownloader();
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_SharedBuild_ExtractsDllsAlongsideExe()
{
var toolsDir = TempDir();
try
{
var downloader = new RecordingDownloader
{
Payload = MakeZip(dllPaths:
[
"ffmpeg-master-latest-win64-lgpl-shared/bin/avcodec-61.dll",
"ffmpeg-master-latest-win64-lgpl-shared/bin/avformat-61.dll",
])
};
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.True(File.Exists(Path.Combine(toolsDir, "avcodec-61.dll")));
Assert.True(File.Exists(Path.Combine(toolsDir, "avformat-61.dll")));
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_EmptyPayload_Throws()
{
var downloader = new RecordingDownloader { Payload = Array.Empty<byte>() };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
[Fact]
public async Task Locate_ZipWithoutFfmpegEntry_Throws()
{
var downloader = new RecordingDownloader { Payload = MakeZip(exePath: "readme.txt") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<InvalidDataException>(() => locator.LocateAsync());
}
[Fact]
public async Task Locate_DownloaderFailure_Propagates()
{
var downloader = new RecordingDownloader { Error = new HttpRequestException("offline") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
var ex = await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.IsType<HttpRequestException>(ex.InnerException);
Assert.Contains("pinned build", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Locate_DeadPin404_ThrowsActionableLocatorError()
{
// 2026-09-01 incident: the pinned autobuild tag aged out of BtbN retention;
// the raw HttpRequestException must never surface from the pump again.
var downloader = new RecordingDownloader
{
Error = new HttpRequestException("Response status code does not indicate success: 404 (Not Found)."),
FileName = resolved,
Arguments = "-version",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
var ex = await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.Contains("PATH", ex.Message);
Assert.Contains("PinnedUrl", ex.Message);
using var process = Process.Start(info)!;
var stdout = process.StandardOutput.ReadToEnd();
Assert.True(process.WaitForExit(milliseconds: 30_000), "bundled ffmpeg -version did not exit in 30s");
Assert.Equal(0, process.ExitCode);
Assert.Contains("ffmpeg version", stdout, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Locate_PrefersTheBundle_OverAPathInstall()
{
var root = TempDir();
try
{
var pathDir = Path.Combine(root, "on-path");
var bundleDir = Path.Combine(root, FfmpegLocator.BundleFolderName);
Directory.CreateDirectory(pathDir);
Directory.CreateDirectory(bundleDir);
File.WriteAllText(Path.Combine(pathDir, FfmpegLocator.FileName), "user's ffmpeg");
var bundled = Path.Combine(bundleDir, FfmpegLocator.FileName);
File.WriteAllText(bundled, "bundled ffmpeg");
var resolved = await new FfmpegLocator([pathDir], root).LocateAsync();
Assert.Equal(bundled, resolved);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task Locate_FallsBackToPath_WhenNoBundleIsPresent()
{
var root = TempDir();
try
{
var pathDir = Path.Combine(root, "on-path");
Directory.CreateDirectory(pathDir);
var onPath = Path.Combine(pathDir, FfmpegLocator.FileName);
File.WriteAllText(onPath, "user's ffmpeg");
var resolved = await new FfmpegLocator([pathDir], root).LocateAsync();
Assert.Equal(onPath, resolved);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task Locate_ZeroByteBundle_IsNotUsable()
{
var root = TempDir();
try
{
var bundleDir = Path.Combine(root, FfmpegLocator.BundleFolderName);
Directory.CreateDirectory(bundleDir);
File.WriteAllText(Path.Combine(bundleDir, FfmpegLocator.FileName), "");
var ex = await Assert.ThrowsAsync<IOException>(
() => new FfmpegLocator([], root).LocateAsync());
Assert.Contains(FfmpegLocator.BundleFolderName, ex.Message, StringComparison.Ordinal);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// A missing bundle must be LOUD and must say where to look. The old ladder had a
/// "found something on disk" path that could quietly satisfy a missing bundle with a
/// stale cached copy — silent drift in exactly the dependency Store certification
/// inspects, and silent drift is how a packaging bug ships.
/// </summary>
[Fact]
public async Task Locate_NoBundleAndNoPath_ThrowsNamingTheBundleFolderAndTheFix()
{
var root = TempDir();
try
{
var ex = await Assert.ThrowsAsync<IOException>(
() => new FfmpegLocator([], root).LocateAsync());
Assert.Contains(FfmpegLocator.BundleFolderName, ex.Message, StringComparison.Ordinal);
Assert.Contains("fetch-ffmpeg.sh", ex.Message, StringComparison.Ordinal);
Assert.Contains("PATH", ex.Message, StringComparison.Ordinal);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The Store-gate assertion, kept as a source-level fact so it cannot rot: the locator
/// must contain no network capability at all. Policy 10.2.2 forbids download-and-execute,
/// and this is the only type in the app that ever executed a downloaded binary. If a
/// future change reintroduces an HttpClient, a URL, or a downloader here, this fails —
/// which is the entire point.
///
/// <para>Patterns are <b>call-shaped</b> (<c>new HttpClient</c>, not <c>HttpClient</c>)
/// on purpose. A bare word match flags the file's own doc comment, which legitimately
/// says "nothing is downloaded" — a test that fails on the prose explaining the
/// guarantee is a test that gets deleted instead of obeyed.</para>
/// </summary>
[Theory]
[InlineData("new HttpClient")]
[InlineData("HttpClient()")]
[InlineData("GetByteArrayAsync(")]
[InlineData("GetStreamAsync(")]
[InlineData("WebRequest.Create")]
[InlineData("DownloadFileTaskAsync")]
[InlineData("http://")]
[InlineData("https://")]
[InlineData("PinnedUrl =")]
[InlineData("ExtractBinaries(")]
public void TheLocatorCarriesNoNetworkCapability(string forbidden)
{
var source = SourceAssert.Text("Services", "Encoder", "FfmpegLocator.cs");
Assert.DoesNotContain(forbidden, source, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The guard that makes a forgotten bundle a build failure rather than a runtime one.
/// Without it, a fresh clone with no <c>ffmpeg/</c> builds cleanly and the app only
/// discovers the problem when the creator presses Record.
/// </summary>
[Fact]
public void TheBuildRefusesToCompileWithoutTheBundle()
{
var csproj = SourceAssert.Text("ytLive.csproj");
Assert.Contains("GuardBundledFfmpeg", csproj, StringComparison.Ordinal);
Assert.Contains("ffmpeg\\ffmpeg.exe", csproj, StringComparison.Ordinal);
Assert.Contains("CopyToPublishDirectory=\"Always\"", csproj, StringComparison.Ordinal);
}
}
+1 -13
View File
@@ -202,7 +202,7 @@ public sealed class InstanceIsolationTests
// DataRoot / WebViewDataFolder are present in both configurations. Note a
// const like YTLIVE_INSTANCE is INLINED at compile time and therefore never
// appears in either binary — grepping for the variable name proves nothing.
var source = File.ReadAllText(RepoFile("Helpers", "InstanceProfile.cs"));
var source = SourceAssert.Text("Helpers", "InstanceProfile.cs");
// The whole promise is that a Release build compiles to the #else arm, so assert
// that arm IS production: the one real user profile, no alternate folder, and no
@@ -225,16 +225,4 @@ public sealed class InstanceIsolationTests
Assert.Contains("instances", debugArm);
}
private static string RepoFile(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(
$"could not locate {Path.Combine(parts)} above {AppContext.BaseDirectory}");
}
}
+31 -6
View File
@@ -4,23 +4,48 @@ using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// Local recording filename rules (TASK 18): <c>ty-&lt;yyyyMMdd&gt;-&lt;HHmm start&gt;-&lt;hh2mm2 length&gt;.mp4</c>.
/// Local recording filename rules (TASK 18):
/// <c>ty-&lt;yyyyMMdd&gt;-&lt;HHmm start&gt;-&lt;fff&gt;-&lt;hh2mm2 length&gt;.mp4</c>.
/// Recordings start with a <c>0000</c> length placeholder and are renamed on stop.
/// </summary>
public class RecordingFileTests
{
/// <summary>Two instances started in the same minute must not compute the same
/// path, or both ffmpeg processes write one file and the take is lost. The
/// millisecond segment is what separates them (dev multi-instance, recording
/// one instance with the other).</summary>
[Fact]
public void TwoStartsInTheSameMinute_ProduceDifferentNames()
{
var first = new DateTime(2026, 9, 27, 14, 54, 0, 123);
var second = new DateTime(2026, 9, 27, 14, 54, 0, 456);
Assert.NotEqual(RecordingFile.BuildStartName(first), RecordingFile.BuildStartName(second));
}
/// <summary>The ms segment must also survive the rename-on-stop, or the final
/// name could collide with a different take started in the same minute.</summary>
[Fact]
public void MillisecondsSurviveTheRenameOnStop()
{
var start = new DateTime(2026, 9, 27, 14, 54, 0, 123);
var name = RecordingFile.BuildFinalName(start, TimeSpan.FromMinutes(5));
Assert.Equal("ty-20260927-1454-123-0005", name);
}
[Fact]
public void BuildStartName_UsesDateHourAndMinute_WithZeroLength()
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
Assert.Equal("ty-20260829-1405-0000", RecordingFile.BuildStartName(start));
Assert.Equal("ty-20260829-1405-000-0000", RecordingFile.BuildStartName(start));
}
[Fact]
public void BuildStartName_PadsSingleDigitHourAndMinute()
{
var start = new DateTime(2026, 1, 2, 9, 7, 0);
Assert.Equal("ty-20260102-0907-0000", RecordingFile.BuildStartName(start));
Assert.Equal("ty-20260102-0907-000-0000", RecordingFile.BuildStartName(start));
}
[Fact]
@@ -28,7 +53,7 @@ public class RecordingFileTests
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
var length = TimeSpan.FromMinutes(2);
Assert.Equal("ty-20260829-1405-0002", RecordingFile.BuildFinalName(start, length));
Assert.Equal("ty-20260829-1405-000-0002", RecordingFile.BuildFinalName(start, length));
}
[Fact]
@@ -36,7 +61,7 @@ public class RecordingFileTests
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
var length = new TimeSpan(1, 15, 0); // 1h 15m
Assert.Equal("ty-20260829-1405-0115", RecordingFile.BuildFinalName(start, length));
Assert.Equal("ty-20260829-1405-000-0115", RecordingFile.BuildFinalName(start, length));
}
[Fact]
@@ -44,6 +69,6 @@ public class RecordingFileTests
{
var start = new DateTime(2026, 8, 29, 14, 5, 0);
var length = new TimeSpan(12, 30, 0); // 12h 30m
Assert.Equal("ty-20260829-1405-1230", RecordingFile.BuildFinalName(start, length));
Assert.Equal("ty-20260829-1405-000-1230", RecordingFile.BuildFinalName(start, length));
}
}
+2 -2
View File
@@ -21,8 +21,8 @@ public sealed class RecordingSaveDialogTests : IDisposable
private readonly string _dir = Path.Combine(
Path.GetTempPath(), "ytLive-recsave-" + Guid.NewGuid().ToString("N")[..8]);
private const string AutoStem = "ty-20260926-1405-0130";
private const string StartStem = "ty-20260926-1405-0000";
private const string AutoStem = "ty-20260926-1405-000-0130";
private const string StartStem = "ty-20260926-1405-000-0000";
private const string Bytes = "fake-mp4-payload";
public RecordingSaveDialogTests() => Directory.CreateDirectory(_dir);
+72
View File
@@ -0,0 +1,72 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace ytLive.Tests;
/// <summary>
/// Shared helpers for the <b>source-level</b> tests — the ones that assert on the text of a
/// source file because the behaviour they guard is structural (a field confined to
/// <c>#if DEBUG</c>, a type carrying no network capability, a build refusing to compile
/// without an asset). Those are legitimate assertions: <c>InstanceIsolationTests</c> has
/// carried one since 2026-08 and it caught a real hoisting bug.
///
/// <para><b>Use <see cref="Literal"/>, not a file-wide string match.</b> Three tests each grew
/// their own <c>File.ReadAllText</c> + <c>RepoFile</c> pair, and two of them then failed on the
/// file's own doc comment — the locator's <c>DoesNotContain("Download")</c> matched the sentence
/// saying "nothing is downloaded", and the auth test's <c>DoesNotContain("force-ssl")</c> matched
/// the comment explaining why force-ssl was removed. Both were fixed by parsing the literal out
/// of the source and asserting on the <i>value</i>. That failure is not a one-off: a file is
/// mostly prose, and the documentation is <em>supposed</em> to name the thing being banned. A
/// grep-based ban asserts "this string never appears", which is a much stronger and much less
/// useful claim than "this value is correct".</para>
///
/// <para>Only the path-walking and the literal extraction live here. Tests that genuinely need to
/// assert on structure — the <c>#if DEBUG</c> arm, the absence of a whole API — should still read
/// the file themselves, because those claims are about code and cannot be reduced to a value.</para>
/// </summary>
internal static class SourceAssert
{
/// <summary>Walks up from the test output directory to the repo root and returns the
/// absolute path of the given repo-relative file.</summary>
public static string RepoPath(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = System.IO.Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(
$"could not locate {System.IO.Path.Combine(parts)} above {AppContext.BaseDirectory}");
}
public static string Text(params string[] parts) => File.ReadAllText(RepoPath(parts));
/// <summary>Extracts the value of a C# string constant or <c>const</c> field declared in the
/// given source file, e.g. <c>Literal("Scope", "Services", "YouTubeAuthService.cs")</c> for
/// <c>private const string Scope = "…";</c>. Whitespace and the trailing semicolon are not
/// part of the value. The path parts are repo-relative and may span directories — the
/// member name comes first so a wrong path can never be mistaken for a member name.
/// </summary>
public static string Literal(string memberName, params string[] parts)
{
var source = Text(parts);
var match = Regex.Match(
source,
$@"(?:const|static readonly|readonly)\s+string\s+{Regex.Escape(memberName)}\s*=\s*""([^""]*)""\s*;");
Assert.True(match.Success,
$"could not find a string constant named '{memberName}' in {System.IO.Path.Combine(parts)} — " +
"if it was renamed or its declaration reshaped, update this test rather than " +
"loosening the assertion.");
return match.Groups[1].Value;
}
/// <summary>The scopes requested in a space-separated OAuth <c>scope</c> const, split into
/// individual scope URIs so a test can assert on the SET rather than on one string.</summary>
public static string[] Scopes(string memberName, params string[] parts) =>
Literal(memberName, parts)
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
+50
View File
@@ -0,0 +1,50 @@
using Xunit;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TRAX load-path guarding (2026-10-04). Startup used to freeze for ~40s when the
/// music track lived on an offline network share: <c>LoadLayout</c> called
/// <c>MusicPlayer.Load</c> synchronously, and <c>new MediaFoundationReader(path)</c>
/// does not fail fast on a dead SMB session — it burns the full timeout and then
/// throws 0x80070035. The fix is a bounded reachability probe on a background thread,
/// so an unreachable share can never sit on the path the creator waits on.
/// </summary>
public class TraxProbeTests
{
/// <summary>The regression this guards: a probe that hangs must be abandoned at the
/// budget and reported offline. Without the ceiling the caller inherits the SMB
/// timeout, which is the 40-second frozen startup window. The orphaned probe thread
/// is released at the end so the test does not leak it.</summary>
[Fact]
public async Task AHangingProbeIsAbandonedAtTheBudget_AndReportsOffline()
{
var orphan = new ManualResetEventSlim(false);
var started = DateTime.UtcNow;
try
{
var reachable = await MainViewModel.IsTrackReachableAsync(
@"\\llamavault\llamaVault\Assets\Music\VBI - Mayhem OST.mp3",
TimeSpan.FromMilliseconds(150),
// Stands in for a share that has gone away: the open never completes.
_ =>
{
orphan.Wait(TimeSpan.FromSeconds(30));
return true;
});
var elapsed = DateTime.UtcNow - started;
Assert.False(reachable);
Assert.True(
elapsed < TimeSpan.FromSeconds(5),
$"probe was not cut off at the budget — took {elapsed.TotalSeconds:0.0}s, which is the frozen-startup bug returning");
}
finally
{
orphan.Set();
}
}
}
+62
View File
@@ -1,5 +1,8 @@
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Xunit;
using ytLive.Models;
using ytLive.Services;
@@ -83,4 +86,63 @@ public class YouTubeAuthServiceTests
Assert.Null(service.CurrentChannel);
}
/// <summary>Creator report 2026-09-27: clicking "Change Account", cancelling in the
/// Google account chooser, then clicking again failed with "cannot sign-in because
/// the callback url was not released". Abandoning the browser gives the app no
/// signal, so the first attempt sat on the loopback callback port for its full
/// 5-minute timeout and the second attempt could not bind.
/// <para>Requires nothing else to be listening on port 8765 — close a running
/// LlamaCasty that happens to be mid-sign-in, or this test sees a genuine
/// "address already in use" from the host.</para></summary>
[Fact]
public async Task RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort()
{
var service = new YouTubeAuthService(
"test-id", "test-secret",
new HttpClient(new FakeHandler("{}", "{}")),
openBrowser: _ => { }); // never shell out to a real browser from a test
var first = service.AuthenticateAsync();
// The creator gives up on the chooser and clicks "Change Account" again.
var second = service.AuthenticateAsync();
// The abandoned attempt must be released promptly rather than left holding the
// port for its whole 5-minute timeout. If it weren't, this await would time out.
Assert.Null(await first.WaitAsync(TimeSpan.FromSeconds(15)));
// Taking over again proves `second` really did bind the port: had it not,
// awaiting it would throw HttpListenerException ("address already in use").
_ = service.AuthenticateAsync();
Assert.Null(await second.WaitAsync(TimeSpan.FromSeconds(15)));
// The last attempt is deliberately left pending — nothing can cancel it and the
// test host exits before its 5-minute timer fires.
}
/// <summary>
/// The app requests exactly ONE scope, and it is the broad one. 2026-09-28:
/// `auth/youtube.force-ssl` was dropped as redundant — its grant ("see, edit, and
/// permanently delete your videos, ratings, comments and captions") is a strict subset of
/// `auth/youtube` ("Manage your YouTube account"), which is also the only scope that can
/// cover live broadcast create/transition, so it was never avoidable.
///
/// Asserted from source because the const is private and the auth flow cannot be exercised
/// without a browser. What is worth locking in is the DECISION — that the redundant scope
/// does not come back — not merely today's string.
///
/// The assertion reads the const's <b>value</b> out of the source rather than scanning the
/// file. A file-wide `DoesNotContain("force-ssl")` fails on this very file's doc comment,
/// which names force-ssl precisely to explain why it went. A test that fails on the prose
/// documenting the guarantee gets deleted instead of obeyed.
/// </summary>
[Fact]
public void ExactlyOneScopeIsRequested_AndItIsTheBroadOne()
{
Assert.Equal(
new[] { "https://www.googleapis.com/auth/youtube" },
SourceAssert.Scopes("Scope", "Services", "YouTubeAuthService.cs"));
}
}
+19
View File
@@ -75,6 +75,25 @@
<None Include="THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<!-- Bundled FFmpeg (TASK 48 item 1, Store policy 10.2.2 dynamic code inclusion).
The app used to DOWNLOAD an unsigned ffmpeg from GitHub at first run and execute
it; that 404'd on the creator's first real recording (2026-09-01) and is the one
hard certification gate. ffmpeg.exe + ffprobe.exe + the libav*.dll family now ship
in the output under a single ffmpeg/ folder — the shared build resolves its DLLs
from the exe's own directory, so they must stay siblings.
The binaries are NOT in git (135 MB). scripts/fetch-ffmpeg.sh populates ./ffmpeg
from a pinned URL, verifying SHA-256 first. CopyToPublishDirectory=Always is the
one that matters for the MSIX package. -->
<ItemGroup>
<None Include="ffmpeg\**" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
</ItemGroup>
<Target Name="GuardBundledFfmpeg" BeforeTargets="CoreCompile">
<Error Condition="!Exists('$(MSBuildProjectDirectory)\ffmpeg\ffmpeg.exe')"
Text="Bundled FFmpeg is missing from ffmpeg\. Run scripts/fetch-ffmpeg.sh once before building (it pins the URL and verifies SHA-256). FFmpeg must be bundled, never downloaded at runtime — that is Windows Store policy 10.2.2, and the previous download path 404'd on 2026-09-01." />
</Target>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ytLive.Tests</_Parameter1>