Compare commits
13
Commits
e7cded6879
...
cc2d1ef622
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2d1ef622 | ||
|
|
ad10176e8b | ||
|
|
f3fed0a2a8 | ||
|
|
d95a23c6fb | ||
|
|
2569c09617 | ||
|
|
3637950df5 | ||
|
|
967df83925 | ||
|
|
b3edc2e42e | ||
|
|
4fcf76289c | ||
|
|
eb0dd91126 | ||
|
|
4873ef85ea | ||
|
|
44344c9327 | ||
|
|
536676f11a |
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,48 @@ using ytLive.Services;
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Local recording filename rules (TASK 18): <c>ty-<yyyyMMdd>-<HHmm start>-<hh2mm2 length>.mp4</c>.
|
||||
/// Local recording filename rules (TASK 18):
|
||||
/// <c>ty-<yyyyMMdd>-<HHmm start>-<fff>-<hh2mm2 length>.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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user