Skip to content

M9: Spotify integration backend + UI + alpha CI/dep consolidation (#101)#919

Merged
Salem874 merged 13 commits into
alphafrom
fix/upstream-gamdl-watch-setup-python-sha
Jun 10, 2026
Merged

M9: Spotify integration backend + UI + alpha CI/dep consolidation (#101)#919
Salem874 merged 13 commits into
alphafrom
fix/upstream-gamdl-watch-setup-python-sha

Conversation

@Salem874

@Salem874 Salem874 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

What's in this PR

PR #919 grew from a focused CI-stability patch into the full M9 Spotify integration backend + UI. All work landed on this single branch per the consolidated-PR direction; the original CI consolidation is the foundation everything else builds on.

CI / infrastructure (original PR scope)

Commit Subject
849ae6d3 release.yml same-channel PREV_TAG + alpha/beta/RC git push rebase-on-conflict retry (5 attempts, 0–4 s jitter) — fixes the body is too long failure that hit alpha.17/19/20 and the alpha.16↔17 lost-cut race
d1fe7216, 87502f96 Original upstream-gamdl-watch.yml SHA rotation + daily cadence (superseded by M9-1's generalised upstream-engine-watch.yml)
2760812a Merge of original PR #918 (closed as superseded)
3f70a0c3 Dep bumps mirroring dependabot PRs #921 + #922 against alpha: i18next 26.3.0→26.3.1, configparser 3.1.0→3.2.0, chrono 0.4.44→0.4.45, log 0.4.30→0.4.32
8681743f Merge of alpha (carries M9-1 #920) — resolved by accepting the generalised engine watcher

M9 Spotify integration — backend

Commit Layer
ac730ab3 M9-2 — Full votify_service + VotifyOptions 35-field CLI table + real VotifyFeature gates (DesktopAacAndMp4Flac 1.9.5+, UpcTag 1.9.7+)
16f4f9d1 M9-3 — Cross-platform best-cover-art picker (Apple Music + Spotify oEmbed; highest-resolution wins; Apple Music tie-break on equal pixel area)
2b72f418 M9-4 — Anti-ban backend: AntiBanSettings model, compute_playback_throttle_delay / compute_inter_track_delay (pure functions), DailyCapCounter with local-midnight rollover, 4 IPC commands, DispatchGateOutcome enum
6065fc20 M9-5start_download accepts open.spotify.com, runs the dispatch gate at IPC entry, SpotifySettings session-type fields, increment_counter helper
5096fe1e M9-6 — Session-artifact dispatch gate (MissingSpotifyDll + MissingWvd outcomes), check_session_artifacts validator, .wvd kept first-class for non-Windows FLAC
b3358d82 M9-7 — Queue dispatch fork to votify with cancellation polling + queue-aware progress events + partial-success detection + crash-restore gate re-eval. Defensive guards on 3 Apple-Music-only post-dispatch helpers. Best-effort manifest write

M9 Spotify integration — frontend

Commit Layer
54938230 M9-UI — Spotify Settings tab + first-run consent modal + RiskPill 3-tier visual cue. Hybrid design from a 2-agent design tournament + adversarial judge

Verification

  • cargo check --lib — clean
  • cargo clippy --lib -- -D warnings — clean
  • cargo test --lib1411 passed, 1 ignored, 0 failed (+~80 new tests across the M9 layers)
  • tsc --noEmit — clean
  • npm run lint — clean
  • Vitest — 550 / 550 passed

Multi-agent quality posture

The two largest pieces (M9-7 queue refactor + M9-UI design) shipped via parallel discovery → synthesis → adversarial-critique workflows. The M9-7 critique surfaced three ship-blockers (broken cancel button, frozen queue row, silent partial-success) — all addressed in the final implementation. The M9-UI verdict picked a hybrid design (utilitarian base + targeted safety borrowings) over either pure design.

Anti-ban posture preserved

  • Default playback_speed_throttle_enabled = true — the flagship mitigation can't be silently regressed.
  • daily_download_cap = 100 default; 0 is the explicit "unlimited" sentinel and requires consent + dev access to set.
  • Dev-access gate + first-run consent modal both required before any Spotify URL can dispatch.
  • IPC-layer gate + per-item dispatch-time gate re-eval closes the crash-restore loophole.
  • Settings tab destructive-edit confirmations (throttle off, cap = 0) name the specific risk before applying.

Deferred to follow-ups

Item Reason
Per-track throttle integration (compute_playback_throttle_delay between tracks) Needs per-track stdout parsing of votify output — M9-8
Per-track counter increment Same — currently per-batch enforcement allows cap overshoot
Spotify-aware metadata pre-fetch (queue-row artist/album from track 1) M9-9 — needs a Spotify metadata API client
Best-cover-art queue integration M9-9 alongside Spotify metadata pre-fetch
Multi-ingest gate wiring (HistoryPage, LibraryScanPage, drag/drop, clipboard) DownloadForm convergence covers the common case; rest is a polish PR
help/spotify-account-risk.md doc Separate help-doc PR
i18n for Spotify copy Alongside the broader M9 localisation pass

Outstanding before merge

  • Per-PR security review (pre-PR security checklist from the standing memory) — flag any non-trivial findings.
  • Maintainer review of the consent-modal copy and the Settings UI tab.

Salem874 added 3 commits June 7, 2026 18:39
…ee channel-release push steps

Two failures hit the 2026-06-07 build chain after PRs #916 + #917
merged 10 seconds apart:

1. **alpha-release.yml for #916 lost the push race against #917.**
   The workflow's `concurrency: alpha-release / cancel-in-progress:
   false` group should serialise same-group runs, but in practice
   GitHub Actions occasionally schedules both rather than queueing.
   #916's run computed counter=19, committed the bump locally, then
   `git push origin alpha` was rejected because #917's bump had
   already landed.

   Fix: refetch + rebase + retry up to 5 attempts with 0-4s jitter
   on each retry. Mirrored to beta-release.yml and
   release-candidate-release.yml — same template, same race
   vulnerability when those channels eventually take traffic.
   The tag is created against the (possibly rebased) HEAD only
   after the branch push succeeds, so the tag annotation always
   points at the actually-pushed commit.

2. **release.yml ensure-release blew through GitHub's 125 000-char
   body limit on v1.11.0-alpha.19.** The tier-2 git-cliff fallback
   matched the wrong PREV_TAG: it searched `v*-alpha.*` and the
   version-aware sort ranked the historic abandoned `v2.0.0-alpha.8`
   (from a 2025 attempt) above the current `v1.11.0-alpha.*` series,
   producing a months-of-commits changelog. Same root cause as the
   earlier alpha.17 incident.

   Fix: constrain the same-channel search to the same base version
   first (`v1.11.0-alpha.*` only when computing PREV_TAG for
   `v1.11.0-alpha.19`). When no match on the current base — i.e. the
   very first prerelease on a freshly bumped version line — fall
   back to the previous base version's same-channel tag, then to the
   previous stable. `awk -v cur="$TAG" '$0 < cur { print; exit }'`
   skips lexicographically-higher leftovers like `v2.0.0-alpha.8`.

Side effects:
* alpha.18's release (which used a pre-staged notes file at
  `.github/release-notes/v1.11.0-alpha.18.md`) was unaffected;
  Tier 1 short-circuited before Tier 2's broken PREV_TAG ever ran.
  This fix is purely Tier 2 / git-cliff path hardening.
* The concurrency-queue rule itself is left in place at the
  workflow level — when it DOES queue (which is the common case),
  the rebase-retry loop is a one-iteration no-op.

Verification:
* `actionlint` clean on all four modified workflows (only the
  pre-existing SC2129 style hints remain on untouched code).

The immediate v1.11.0-alpha.19 build was unblocked separately by
creating the Release object manually via `gh release create` and
dispatching `release.yml` against the existing tag (Run ID
27099866210). This commit fixes the underlying mechanic so future
alpha cuts don't need the manual recovery.
…(5-week outage recovery)

The weekly Monday-08:00-UTC `Upstream GAMDL Watch` workflow has been
failing on every run since 2026-05-04 with:

  Unable to resolve action `actions/setup-python@e348410041c5b0ca4452c8e292ca3936bac9ba7f`,
  unable to find version `e348410041c5b0ca4452c8e292ca3936bac9ba7f`

The pinned SHA refers to a v6 release commit that was either retagged
or rotated upstream — GitHub Actions can no longer fetch the action.
Repointing to the current `actions/setup-python@v6` SHA
(`a309ff8b426b58ec0e2a45f0f869d46889d02405`) restores the watch.

Impact of the 5-week outage:

* No automated audit issue for GAMDL v3.5.2 → v3.7.x transitions.
* The v3.7.2 + v3.7.3 audit (#898) was caught manually by user
  observation, not by the watcher.
* No regression in MeedyaDL functionality — the watcher only files
  notification issues, it doesn't gate any user-facing behaviour.

Going forward this watcher is the foundation pattern for tracking
every download engine. The same workflow shape will be cloned for
votify (M9 / #101) and any future engine — keeping the support-
window discipline that produced #767, #711, #898, etc.

Lessons learnt from this incident, to fold into other workflows:

* SHA-pinned actions need a periodic refresh. Consider Dependabot
  `github-actions` ecosystem coverage — it auto-PRs new SHAs as
  upstream actions re-tag. Currently we pin manually.
* The watcher itself produced no signal that it was broken — its
  failure is silent unless a maintainer checks the Actions tab.
  Worth adding a `failure: notify-issue` step so future outages
  open a tracking issue on their own.

Both lessons tracked as follow-ups.

Verification: `actionlint` clean on the modified file; no other
changes.
…daily 06:00 UTC

GAMDL has shipped multiple point releases within 24 hours of each
other on several occasions in 2026:

* v3.4 + v3.5 — 2026-04-27 same-day pair
* v3.7.2 + v3.7.3 — 2026-05-28 same-day pair

Under the previous weekly Monday 08:00 UTC cadence those releases
collapsed into a single audit ticket, even though each had a distinct
diff worth its own short review window. A daily run gives every
release its own first-look slot before the next one lands and the
open ticket becomes harder to triage by accretion.

06:00 UTC chosen as a quiet window across global maintainer time
zones (06:00 GMT / 07:00 BST / 02:00 EST / 23:00 PST previous day /
15:00 JST), far from typical CI rush hours.

The "update existing issue rather than open new" logic in the
notification step was already in place — it now becomes load-bearing
rather than belt-and-braces: at the daily cadence, a stale ticket
would be touched 7× more often than before, so the dedup matters more.

Same cadence will apply to the votify upstream watcher landing in
PR M9-1 and every future engine watcher. One daily 06:00 UTC window
polls all configured engines via the generalised
`upstream-engine-watch.yml` pattern.

Combined with the SHA-pin fix from earlier in this same PR, this
commit restores the watcher to a usable state AND tightens its
cadence to match GAMDL's actual upstream cadence.
@Salem874 Salem874 changed the title fix(ci): bump stale actions/setup-python SHA in upstream-gamdl-watch (5-week outage recovery) fix(ci): restore upstream-gamdl-watch (stale setup-python SHA) + tighten cadence to daily 06:00 UTC Jun 7, 2026
Salem874 added a commit that referenced this pull request Jun 9, 2026
…y gates + generalised engine watcher (#101) (#920)

## Summary

**Phase 1 of the M9 Spotify integration EPIC**
([#101](#101)). Pure
scaffolding — registers the votify engine, lands the capability-gate
machinery, generalises the upstream PyPI watcher to cover both GAMDL and
votify. **No user-visible Spotify download behaviour ships in this PR.**
Subsequent M9-* PRs add the actual subprocess wiring (M9-2),
best-cover-art foundation (M9-3), anti-ban stack (M9-4), and lossless
gates (M9-5, M9-6).

## What lands

### Backend foundation

| Change | Why |
|---|---|
| `engines.toml`: votify `enabled = true` | Engine registry can now
resolve `votify` to a real builder + the Spotify platform's primary
engine. |
| `tool-versions.toml`: new `[votify]` section (`1.9.0..=1.9.9`) |
Bounds `pip install` to a tested range; feeds the "Untested" badge on
the Updates page when upstream ships beyond ceiling. |
| New `src-tauri/src/services/votify_capabilities.rs` (~500 LOC) |
Mirrors the entire `gamdl_capabilities` shape: classify /
pip_version_spec / pip_target_spec / supports /
active_capabilities_summary + the version cache. Single placeholder
`VotifyFeature` variant (real version-conditional gates land in PR M9-2
after the votify CLI audit). |
| `engine_runner.rs`: `VotifyCommandBuilder` upgraded from the
v2.0.0-stamped error to a real builder | Engine registry can now
construct the builder; dispatch still returns a friendly "see #101 for
status" error since the real subprocess plumbing lands in PR M9-2. |
| `commands/dependencies.rs`: new `probe_votify_version()` helper called
from `get_component_versions` | Populates `votify_capabilities`'s
version cache at startup so the Updates page + Activity Log report
installed votify version alongside GAMDL. Replaced by
`votify_service::get_votify_version` in PR M9-2. |

### Generalised engine watcher

| Change | Why |
|---|---|
| New `.github/workflows/upstream-engine-watch.yml` (matrix over `gamdl`
+ `votify`) | One workflow, multiple engines. Adding M8 BBC iPlayer /
M10 YouTube watchers becomes a one-row matrix addition + a `[<engine>]`
block in tool-versions.toml. |
| Daily 06:00 UTC cron | Same justification as [PR
#919](#919): GAMDL has
shipped multiple point releases within 24 h of each other in 2026; daily
polling gives each release its own audit window. |
| Per-engine audit labels (`upstream-gamdl`, `upstream-votify`) +
per-engine issue threads | Audits stay in their own thread; ceiling-bump
PRs touch the right ticket. |
| `actions/setup-python` pinned to current v6 SHA | Same SHA PR #919
uses. If #919 merges first, the new file inherits the already-fixed pin.
If this PR merges first, the new file replaces the broken one directly.
|
| `upstream-gamdl-watch.yml` deleted | Subsumed by the generalised
version. |

## Anti-ban posture preserved

The engine is registered but **dispatch returns a friendly "Spotify
download support is in active development" error** with a link to #101.
Users on a fresh install never see Spotify in the Download form yet —
the UI surface that exposes Spotify URLs lands in PR M9-4 alongside the
dev-access gate, the first-run modal, and the real-time playback
throttle.

This intentional staging means PR M9-1 ships zero user-facing Spotify
behaviour, which is the right risk profile for landing pure
infrastructure on the alpha channel before the anti-ban stack is in
place.

## Coordinating with PRs #918 + #919

| Order | Result |
|---|---|
| #918#919 → M9-1 | Cleanest. #918's PREV_TAG fix lands first, then
#919's watcher SHA + cadence is the last thing the old watcher does
before this PR retires it. |
| M9-1 → #918#919 | M9-1 deletes `upstream-gamdl-watch.yml`
immediately; #919 becomes a no-op (file gone). Still works. |
| #919 → M9-1 → #918 | #919 fixes the old watcher; M9-1 retires it
cleanly. Also fine. |

No actual merge-conflict risk regardless of order — M9-1's only edit to
`upstream-gamdl-watch.yml` is `git rm`, so any prior change to that file
simply gets removed at merge time.

## Verification

- [x] `cargo test --lib` — **1337 / 1337 pass** (+24 new tests for
`votify_capabilities`)
- [x] `cargo clippy --lib -- -D warnings` — clean
- [x] `npx tsc -p tsconfig.json --noEmit` — clean (no frontend changes)
- [x] `npm run lint` — clean
- [x] `npm test` (Vitest) — **550 / 550 pass**
- [x] `actionlint .github/workflows/upstream-engine-watch.yml` — clean
- [ ] **Post-merge smoke** (alpha-release): the alpha cut produced by
this PR's merge ships `1.11.0-alpha.20`. Dispatch the new watcher via
`gh workflow run "Upstream Engine Watch"` and confirm: (a) both `gamdl`
and `votify` matrix rows pass, (b) neither files an audit issue against
current ceilings (`3.7.3` and `1.9.9` respectively).

## Out of scope (deferred per the M9 EPIC roadmap)

- **M9-2** — Full `votify_service` + `VotifyOptions` CLI flag table +
`VotifyFeature` real variants
- **M9-3** — `best_cover_art_service` (cross-platform,
highest-resolution-wins, tie-break Apple Music — design
[comment](#101 (comment)))
- **M9-4** — Anti-ban stack (real-time playback throttle, inter-track
delay + jitter, daily cap, dev-access gate + first-run modal) + Settings
UI surface
- **M9-5** — Lossless via `desktop` session (Spotify DLL 1.2.88.483)
- **M9-6** — Lossless via `web` session (Widevine `.wvd`); decision
point for lifting the dev-access gate

## Related

- **#101** — Multi-PR Spotify integration EPIC; [full plan
body](#101 (comment))
+ [cover-art
revision](#101 (comment))
- **#918** — CI fix (PREV_TAG + alpha/beta/RC race)
- **#919** — SHA fix + daily cadence for the GAMDL watcher (this PR
generalises that watcher)
- **#107** — Multi-service architecture foundation (engine_registry,
engine_runner) this PR builds on
- **#898** — GAMDL v3.7.2 + v3.7.3 audit (last manual one; the new daily
watcher should auto-file the next)
- **`.claude/memory/project_gamdl_release_cadence.md`** — audit-cadence
pattern the new watcher continues

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Salem874 added 2 commits June 9, 2026 09:22
…chrono 0.4.44→0.4.45 + log 0.4.30→0.4.32

Mirrors the upstream dependabot bumps from #921 + #922
(both originally targeted `main`). Applied here to the alpha
line so the consolidated CI-fix branch carries the same
patch-level versions. #921 + #922 land separately on main
via their dependabot PRs.
@Salem874 Salem874 changed the title fix(ci): restore upstream-gamdl-watch (stale setup-python SHA) + tighten cadence to daily 06:00 UTC fix(ci): consolidated alpha-line stability — release pipeline + upstream watcher + dep bumps (supersedes #918) Jun 9, 2026
Salem874 added 8 commits June 9, 2026 09:33
…onsolidated CI PR

# Conflicts:
#	.github/workflows/upstream-gamdl-watch.yml
…LI flag table + real capability gates (#101)

## VotifyOptions
* Expanded from a 9-field stub to a 35-field model covering the full
  votify >= 1.9 CLI surface — General, Spotify session, Output,
  Template (10 templates), Audio, Video, and the six executable-path
  overrides. Every field is `Option<T>` so the existing GamdlOptions-
  shaped merge pattern (per-download overrides on top of global
  defaults) carries over unchanged.
* New `to_cli_args()` emits `--flag value` pairs in struct-grouped
  order. Booleans only emit when `true`, preserving votify's CLI
  defaults when the user hasn't expressed a preference.
* `wait_interval` is wired here so M9-4's real-time playback-speed
  throttle can plug into the existing flow without another model
  change.

## spotify_service.rs
* `install_votify(app, target_version)` — pip-installs into the
  managed Python, bounded by the M9-1 support window
  (`votify>=1.9.0,<=1.9.9`) on the routine path and pinned to an
  exact version when the user opts into an above-ceiling "Untested"
  release.
* `get_votify_version` — `python -m pip show votify` with a 10 s
  timeout, refreshing the shared capability cache so consumers see
  the new version immediately.
* `check_latest_votify_version` — PyPI JSON API lookup mirroring
  `check_latest_gamdl_version`.
* `build_votify_command_public` / internal `build_votify_command` —
  spawns `python -m votify <urls> <flags>` with the same URL
  prefix-validation that gamdl_service uses (prevents `--`-prefixed
  arguments from being misinterpreted as flags), and auto-injects
  managed FFmpeg / MP4Box / mp4decrypt paths via
  `dependency_manager::get_tool_binary_path` when the user hasn't
  overridden them.
* `run_votify` — delegates to `engine_runner::run_engine` so stdout
  / stderr streaming, parsed-event emission, and exit-status
  handling are shared with every future engine (yt-dlp, get_iplayer).

## engine_runner::VotifyCommandBuilder
* Replaced the M9-1 "Spotify support is in active development"
  placeholder with a real delegation to
  `spotify_service::build_votify_command_public`. Mirrors
  `GamdlCommandBuilder`'s shape.

## VotifyFeature real variants
* Replaced the M9-1 `Placeholder` variant with two concrete gates
  driven by the upstream release-note audit:
  * `DesktopAacAndMp4Flac` — votify v1.9.5+ ("Allow AAC and MP4
    FLAC for desktop session type.")
  * `UpcTag` — votify v1.9.7+ ("Added UPC tag")
* `active_capabilities_summary()` now emits stable kebab-case
  identifiers (`desktop-aac-and-mp4-flac, upc-tag`) so activity-log
  scraping stays robust across releases.

## Anti-ban posture preserved
* This PR ships **no UI-visible Spotify download path**. The engine
  resolver returns a real command builder, but no Settings surface,
  download form route, or queue entry path can produce a votify
  invocation until M9-4 lands the dev-access gate + the first-run
  anti-ban consent modal + the real-time throttle.
* Default `VotifyOptions` produces an empty CLI argv — the queue's
  merge layer is where session_type / audio_quality defaults get
  applied (and that's M9-4 territory).

## Verification

* `cargo check --lib` — clean
* `cargo clippy --lib -- -D warnings` — clean
* `cargo test --lib` — 1348 passed (1 ignored), 0 failed (+13 new
  M9-2 tests across VotifyOptions and votify_capabilities)
* `npx tsc -p tsconfig.json --noEmit` — clean
* Vitest — 550 / 550 passed
…usic tie-break (#911-cover-art / #101)

## What this PR adds

* New `services/best_cover_art_service.rs` — pure infrastructure for
  picking the highest-resolution static cover art across every
  supported music platform. Today: Apple Music + Spotify (oEmbed).
  Tomorrow: every adapter just adds a `CoverArtSource` variant and a
  fetcher function and joins the parallel fan-out.
* Comparator: pixel area descending, with Apple Music as the
  equal-pixel tie-break (per #911 EPIC and direct user feedback —
  "if quality… are equal use Apple Music").
* `apple_music_candidate(&AlbumMetadata)` — pure, no network. Renders
  the `{w}x{h}{c}.{f}` template at the API's reported max width /
  height (typically 3000×3000 for modern releases).
* `fetch_spotify_candidate(album_url)` — public oEmbed endpoint. No
  auth, no Spotify Web API client credentials. Returns `Ok(None)`
  on 4xx / missing fields so the caller can always proceed with
  whatever Apple Music returned.
* `find_best_cover_art(&BestCoverArtRequest)` — orchestrator. Fans
  out the per-platform fetches and feeds the comparator.

## Settings + opt-in

* New `best_cover_art_enabled: bool` on `AppSettings` (Rust + TS),
  default `false`. Opt-in because:
  * Issues one extra HTTP call per non-Apple platform per album
  * Most users are happy with the originating engine's cover art
  * Settings surface lives at Settings > Cover Art (UI lands in
    a later PR — this commit is service infrastructure only)

## Anti-ban posture

* Best-cover-art only touches public metadata / oEmbed endpoints
  — never downloads through a service's download engine. The
  Spotify account-ban surface (M9-4's anti-ban stack) is unaffected
  whether this feature is on or off.

## Integration timing

* This PR is pure infrastructure. The queue layer does **not** call
  `find_best_cover_art` yet — that integration is M9-4 territory
  where service-aware dispatch lands. So nothing ships any
  user-visible behaviour change on this commit; the picker is
  reachable from a unit test and from the future M9-4 enrichment
  hook.

## Verification

* `cargo check --lib` — clean
* `cargo clippy --lib -- -D warnings` — clean
* `cargo test --lib` — 1362 passed (1 ignored), 0 failed
  (+14 new best_cover_art tests covering the comparator, the
  tie-break, the Apple template rendering, the Spotify oEmbed
  parser, and the orchestrator's empty-input behaviour)
* `tsc --noEmit` — clean
* ESLint — clean
* Vitest — 550 / 550 passed
…-access + consent dispatch gate, IPC) (#101)

## What this PR adds

### Anti-ban settings + state

* `models/spotify_anti_ban.rs::AntiBanSettings` (5 fields):
  * `playback_speed_throttle_enabled: bool` (default true)
  * `inter_track_delay_seconds: u32` (default 10)
  * `inter_track_jitter_seconds: u32` (default 5)
  * `daily_download_cap: u32` (default 100; `0` = unlimited)
* Nested into `SpotifySettings::anti_ban` so every Spotify-side
  download surface inherits the safety knobs without touching
  global `AppSettings` callers.
* New `AppSettings::spotify_consent_acknowledged: bool` (default
  false) tracks the first-run modal acknowledgment.

### Runtime — pure-function throttle math + persisted counter

* `services/spotify_anti_ban.rs`:
  * `compute_playback_throttle_delay(settings, track_ms, elapsed_ms)`
    — returns the sleep duration that brings wall-clock to ≥
    track-runtime. Returns `Duration::ZERO` when the throttle is
    off or the network was already slow enough.
  * `compute_inter_track_delay(settings, rng_value)` — uniform on
    `[base, base + jitter)`. Pure (rng injected) for tests; the
    `_random()` sibling wraps `rand::random` for production.
  * `DailyCapCounter` — persisted as
    `{app_data}/spotify_daily_counter.json`, atomic-rename writes,
    automatic local-midnight rollover via `with_rollover()`.
  * `would_exceed(settings)` is the per-call check; `0` cap is the
    "unlimited" sentinel.

### IPC layer + dispatch gate

* `commands/spotify_anti_ban.rs` ships four Tauri commands:
  * `acknowledge_spotify_consent` — flips the persistent flag.
  * `get_spotify_daily_cap_status` — read-only snapshot for the
    UI to render "N / cap downloaded today".
  * `reset_spotify_daily_cap_counter` — defence-in-depth checks
    `dev_access_enabled` before zeroing the counter.
  * `check_spotify_dispatch_allowed` — preview the gate's answer
    before showing the Spotify path in the download form.
* `DispatchGateOutcome` enum carries one of {Allowed,
  DevAccessRequired, ConsentRequired, DailyCapReached}. The
  evaluation order is deliberate: dev-access first (so users see
  the "unlock" path before any other modal), consent second, cap
  last. Tests pin every transition.

### TypeScript + Vitest

* `AppSettings.spotify_consent_acknowledged: boolean` mirrored to
  `src/types/index.ts`.
* `settingsStore.ts` default updated; both `settingsStore.test.ts`
  fixtures extended.

## Anti-ban posture (what doesn't ship in this PR)

This PR is **backend-only**. There is no UI surface yet:
* No Settings > Services > Spotify panel
* No first-run consent modal component
* No dispatch wiring — `start_download` still rejects Spotify URLs
  at the host allowlist. The gate function is reachable from the
  IPC preview but the queue path that consumes its `Allowed`
  outcome lands in M9-5 / M9-6.

That's intentional. The safety primitives + the IPC contract
have to be in the build first so the UI work can target a stable
backend surface.

## Verification

* `cargo check --lib` — clean
* `cargo clippy --lib -- -D warnings` — clean
* `cargo test --lib` — 1388 passed (1 ignored), 0 failed
  (+26 new across anti-ban model, service, and IPC modules:
  throttle pure-function tests, NaN safety, jitter clamping,
  rollover edges, cap = 0 sentinel, dispatch-gate precedence
  order)
* `tsc --noEmit` — clean
* `npm run lint` — clean
* Vitest — 550 / 550 passed
…_type wiring + counter increment (#101)

## What this PR adds

### URL allowlist + dispatch gate at IPC boundary

* `start_download` now accepts `open.spotify.com` URLs alongside
  the existing Apple Music hosts. When any URL in a batch is a
  Spotify URL, the four-outcome dispatch gate from M9-4 fires
  **before** any queue mutation:
  * `Allowed` → proceeds (queue routing to votify is a follow-up).
  * `DevAccessRequired` → user-facing error pointing at the
    Settings > Advanced > Developer Tools unlock path.
  * `ConsentRequired` → user-facing error pointing at the
    first-run consent modal (UI lands in a follow-up).
  * `DailyCapReached { count, cap }` → user-facing error showing
    today's count and cap with a "resets at local midnight" hint.

  Same `evaluate_dispatch_gate` function the IPC preview uses, so
  the preview and the enforcement can never diverge. A mixed-
  service batch (Apple + Spotify pasted together) fails fast on
  the Spotify gate rather than partially-queueing.

### SpotifySettings session-type fields

* New nested fields on `SpotifySettings`:
  * `session_type: Option<String>` — `"librespot"` / `"desktop"` /
    `"web"`. Free-form string to match votify's CLI shape exactly.
  * `spotify_dll_path: Option<String>` — used when session_type
    is `"desktop"` (M9-5 FLAC support).
  * `wvd_path: Option<String>` — used when session_type is
    `"web"` (M9-6 FLAC support).
* The actual queue dispatch wiring (votify + DLL + Widevine) is
  a follow-up. This PR lands the model surface so the Settings
  UI work in a later PR has a stable shape to target.

### Daily-cap counter increment

* New `services::spotify_anti_ban::increment_counter(app, tracks)`
  — saturating-add to avoid u32 wrap-around (defence-in-depth;
  the gate check at dispatch time already prevents this in
  practice). Returns the post-increment counter so the caller
  can surface "X / cap downloaded today" in the activity log
  without a second disk read.
* The actual call site for `increment_counter` lives in the
  queue-routing PR — this commit just makes the helper available.

### TypeScript settings type sync

* `SpotifyServiceSettings` extended with `session_type`,
  `spotify_dll_path`, `wvd_path`, and `anti_ban` (the nested
  `AntiBanSettings` block — new exported interface mirroring
  the Rust struct).

## Anti-ban posture preserved

* IPC layer rejects Spotify URLs when dev_access_enabled is off
  OR consent isn't acknowledged. So even though the host
  allowlist now accepts `open.spotify.com`, no track can reach
  the votify subprocess without two explicit user gates having
  been cleared. The actual queue routing to votify (and the
  real-time throttle integration) is a follow-up — what we have
  today is "the gate is enforced at the IPC boundary; downstream
  queue dispatch still rejects the item with a clear in-development
  message" until that wiring lands.

## Verification

* `cargo check --lib` — clean
* `cargo clippy --lib -- -D warnings` — clean
* `cargo test --lib` — 1391 passed (1 ignored), 0 failed
  (+3 new across the counter saturation contract + the
  DispatchGateOutcome `kind`-discriminator serialization
  shape the React layer depends on)
* `tsc --noEmit` — clean
* `npm run lint` — clean
* Vitest — 550 / 550 passed
…idevine .wvd) with first-class web-session support (#101)

## What this PR adds

### Two new DispatchGateOutcome variants

* `MissingSpotifyDll` — fires when `session_type = "desktop"` but
  `spotify_dll_path` is unset / empty / points at a non-existent
  file. The Spotify Windows desktop DLL `1.2.88.483` is a
  user-supplied external artefact MeedyaDL can't ship.
* `MissingWvd` — fires when `session_type = "web"` but `wvd_path`
  is unset / empty / points at a non-existent file. The Widevine
  `.wvd` is also a user-supplied external artefact (extracted from
  rooted Android or via tools like pywidevine — non-trivial
  acquisition path documented for the eventual help-page link).

Both are first-class in the gate enum (NOT relegated to a
generic catch-all), so the React layer can render specific,
actionable error copy rather than "votify failed."

### `check_session_artifacts` validator

* New pure-ish helper in `commands::spotify_anti_ban`. Returns
  `None` (no validation required) for `librespot` / unset / any
  future session_type we haven't taught the gate about, or
  `Some(MissingSpotifyDll | MissingWvd)` when the configured
  session needs an artefact that's missing.
* Filesystem-touching but does not mutate — only `Path::is_file()`.
  A deleted-mid-flight artefact resurfaces as a clean error on
  the next dispatch attempt.

### Updated gate evaluation order

Was `dev_access → consent → cap`; now
`dev_access → consent → artifact → cap`. Artifact takes precedence
over the cap because we'd rather tell the user "your DLL is missing"
than "you're at the cap" for a download they can't attempt anyway.

### Mapped error copy at `start_download`

Both new outcomes get user-facing error strings that point at the
Settings panel + offer the librespot fallback. The Widevine error
explicitly notes that `.wvd` is user-supplied and references the
help page (which lands as part of the Settings UI work in a
follow-up).

### TypeScript discriminated union

* New `DispatchGateOutcome` type union mirroring the Rust enum.
  React routes on `kind` — the discriminator now covers all six
  outcomes (`allowed`, `dev_access_required`, `consent_required`,
  `missing_spotify_dll`, `missing_wvd`, `daily_cap_reached`).

## Why `.wvd` remains a first-class option

Acquiring a Widevine `.wvd` is harder than the desktop DLL, but
the trade-off is clear: web session is the only path to FLAC for
users on macOS / Linux (where the Spotify desktop DLL is
Windows-only). Treating `.wvd` as a deprecated / hidden option
would silently regress every non-Windows user who wants lossless.
The dispatch gate's `MissingWvd` outcome carries enough context
for the UI to surface acquisition guidance without claiming
MeedyaDL itself ships the file.

## Verification

* `cargo check --lib` — clean
* `cargo clippy --lib -- -D warnings` — clean
* `cargo test --lib` — 1404 passed (1 ignored), 0 failed
  (+13 new across session-artifact validation, gate composition,
  discriminator serialization, librespot/unset/unknown fallthrough,
  and real-file / empty-path / nonexistent-path edges for both
  desktop and web)
* `tsc --noEmit` — clean
* `npm run lint` — clean
* Vitest — 550 / 550 passed
…, progress, partial-success detection, and crash-restore gate re-eval (#101)

Multi-agent workflow output: 4 parallel discovery agents → synthesis → adversarial critique. Critique surfaced three ship-blocking gaps in the initial design that are addressed here.

## Queue-aware engine runner

* New `engine_runner::run_engine_with_queue` — sibling to the existing
  `run_engine`, adds two things the queue-less variant lacks:
  * **250 ms cancellation polling**: `q.is_cancelled(dl_id)` check on
    every tick, `child.kill()` on positive hit. Without this, the
    Cancel button would be functionally broken for Spotify items —
    votify subprocess would keep running for the entire album.
  * **`update_item_progress` per parsed event**: queue row caption /
    progress / track counters tick during the run. Without this,
    Spotify rows freeze at the initial state until set_complete
    snaps them to 100% — the misleading-snap behaviour Phase 3.5
    explicitly eliminated.

## Top-of-loop dispatch fork

* `process_queue` now consults `item.engine` (NOT `item.service`)
  after the existing `is_apple_music` binding. Engine-keyed dispatch
  is forward-compatible with M10's shared `ytdlp` engine across
  YouTube + BBC iPlayer; the critique flipped this from service-keyed
  for that reason.
* `Some("votify")` → routes to `run_spotify_dispatch_arm` and
  `return`s (process_queue is recursive, not loop-bodied).
* `Some("gamdl") | None` → falls through to existing GAMDL flow.

## `run_spotify_dispatch_arm` — the M9-7 centerpiece

Owns the entire lifetime of a Spotify queue item:

1. **Gate re-validation** (closes crash-restore loophole). The
   four-outcome dispatch gate runs AGAIN here, not just at the IPC
   boundary. Crash-restored Spotify items used to bypass it; now
   they don't. All six DispatchGateOutcome variants map to specific
   activity-log breadcrumbs.
2. **Pre-run audio-file count snapshot** for partial-success
   detection.
3. **`VotifyOptions::from_settings`** — new helper in
   `models::votify_options` that builds the typed options from the
   global `SpotifySettings`. Anti-ban knobs deliberately NOT
   propagated yet (waiting for M9-8's per-track instrumentation).
4. **Spawned tokio task** with `ActiveSlotGuard` for panic safety:
   * `run_engine_with_queue` — drives votify with cancellation +
     progress.
   * On Ok: re-scan audio files. **Zero new files** → `set_error`
     ("votify exited cleanly but produced no new audio files —
     every track failed"). Otherwise → `set_complete`, increment
     daily-cap counter by new-file count, warn loudly if the
     counter crossed the cap boundary (known M9-8 limitation:
     the cap is enforced once per batch and can be overshot on
     near-cap dispatches).
   * Best-effort `write_manifest` so Library Scan sees Spotify
     albums. `album_metadata = None`, `primary_codec_id =
     Some("vorbis")`. Skips with WARN when album_dir resolution
     fails.
   * On Err: `set_error` (cancellation sentinel guarded by #661
     terminal-state protection).
5. **Slot release**: explicit `on_task_finished` then
   `guard.disarm()` — same order as GAMDL completion task to
   prevent double-decrement.
6. **Cascade**: fire-and-forget `process_queue(app, queue)` so the
   next item dispatches.

## Defensive guards (belt-and-braces)

Three Apple-Music-only post-dispatch helpers now refuse to process
Spotify URLs even when reached via future regression:

* `download_music_video_by_url` (line ~4988)
* `run_lyrics_fallback` (line ~5333)
* `spawn_companion_downloads` (line ~5944)

Each early-returns on `urls.contains("open.spotify.com")` or
`urls.starts_with("spotify:")` with a clear activity-log entry.
These are belt-and-braces because the dispatch fork already
short-circuits Spotify items before these can be reached.

## Tests

* 7 new tests in `download_queue::tests`:
  * Spotify URL detection (`open.spotify.com` / `spotify:` URI /
    Apple Music negatives / substring-attack negatives).
  * `VotifyOptions::from_settings` round-trip from queue perspective.
  * Dispatch fork engine-string pinning (votify, not service).
* 1 new test in `votify_options::tests`:
  * `from_settings` propagates session artefacts but NOT anti-ban
    knobs (the M9-8 boundary).

## Critique → addressed

| Ship-blocker (from adversarial workflow) | M9-7 fix |
|---|---|
| Cancel button broken for Spotify | `run_engine_with_queue` 250 ms poll loop |
| Queue row frozen during download | `update_item_progress` per parsed event |
| Partial-success looks like complete | Post-run audio-file count check |
| Crash-restore bypasses dispatch gate | Re-evaluate gate in dispatch arm |

## Known limitations (deferred)

* Per-track throttle (`compute_playback_throttle_delay` /
  `compute_inter_track_delay`) — M9-8 (needs per-track stdout
  parsing).
* Per-track counter increment — M9-8 (cap is currently enforced
  per-batch; near-cap dispatches can overshoot. WARN logs surface
  this when it happens).
* Spotify-aware metadata pre-fetch for queue-row artist/album —
  M9-9.
* Spotify Settings UI tab + first-run consent modal — M9-UI
  (lands alongside this PR per the UI workflow's "simultaneously"
  verdict).

## Verification

* `cargo check --lib` — clean
* `cargo clippy --lib -- -D warnings` — clean
* `cargo test --lib` — 1411 passed (1 ignored), 0 failed (+7 new)
* `tsc --noEmit` — clean
* `npm run lint` — clean
* Vitest — 550 / 550
… + RiskPill helper (#101)

Implements the hybrid design verdict from the M9-UI workflow:
Design A's tight single-PR structure + targeted borrowings from
Design B (RiskPill, destructive-edit confirmations, revoke-consent
button).

## New components

* `common/RiskPill.tsx` — three-tier visual cue
  (`lower` / `higher` / `highest`) for settings controls.
  Icon + text — WCAG 1.4.1 compliant (no colour-only signalling).
  Reusable across services for any "consequence-of-changing" cue.
* `common/SpotifyConsentModal.tsx` — single-screen first-run consent
  flow. Default focus on dismiss (an accidental Enter dismisses,
  not accepts). Mixed-batch behaviour: if the user pasted Apple +
  Spotify URLs and dismisses, NO URLs queue — the toast tells them
  how to retry with only the non-Spotify URLs.
* `settings/tabs/SpotifyTab.tsx` — replaces the M9-1 "Coming Soon"
  placeholder with four sections:
  1. **Session** — session_type Select (librespot / desktop / web),
     conditional FilePickerButton for spotify_dll_path (desktop) or
     wvd_path (web), and the optional cookies_path.
  2. **Anti-ban safeguards** — the four AntiBanSettings knobs, each
     annotated with `<RiskPill />`. Destructive edits (throttle off,
     cap = 0) trigger `useConfirmation` modals with copy that names
     the specific risk.
  3. **Daily cap status** — live snapshot via
     `get_spotify_daily_cap_status`. Refetches on window focus.
     Dev-access-gated "Reset counter" button.
  4. **Risk acknowledgement** — read-only consent flag display +
     "Revoke consent" button (non-dev-gated, confirmation-modal'd).

## Wiring

* `App.tsx` — mounts `<SpotifyConsentModal />` alongside
  `<CrashReportOptInModal />`.
* `SettingsPage.tsx` — adds the Spotify tab to TABS and registers a
  new "Services" SETTINGS_GROUP between Authentication and System.
* `DownloadForm.runPreflightAndSubmit` — between the output-path and
  cookie checks, calls `checkSpotifyDispatchAllowed` for any batch
  containing a Spotify URL. Routes each `DispatchGateOutcome` to
  the right UX:
  * `allowed` → proceed
  * `consent_required` → park preflight on the consent modal's
    accept callback (re-invokes runPreflightAndSubmit rather than
    stashing pre-built request state — verdict's race-condition
    mitigation)
  * `dev_access_required` / `missing_spotify_dll` / `missing_wvd` /
    `daily_cap_reached` → user-facing toast + return

## State plumbing

* `lib/tauri-commands.ts` — typed wrappers for the four M9-4/UI
  IPCs (`acknowledge_spotify_consent`,
  `get_spotify_daily_cap_status`, `reset_spotify_daily_cap_counter`,
  `check_spotify_dispatch_allowed`) + `DailyCapStatus` interface
  mirroring the Rust struct.
* `stores/uiStore.ts` — `showSpotifyConsent` + `pendingSpotifyConsentCallback`
  slots + setters.
* `components/common/index.ts` — `RiskPill` + `RiskTier` exports.
* `components/settings/tabs/SettingsTabs.test.tsx` — lucide-react
  mock extended with ShieldCheck / Shield / ShieldOff / Headphones.

## Verdict-aligned choices

* **Single-screen consent** (not Design B's 3-screen wizard) —
  consent is a yes/no question; anti-ban tuning lives in the tab.
* **No covert settings-mutation in the consent flow** — the
  IPC is the single source of truth for the flag write. Frontend
  does NOT also call `updateSettings({ spotify_consent_acknowledged: true })`
  — would race the IPC's own settings save.
* **patchSpotify / patchAntiBan helpers** (not a custom
  `useSpotifySettingsField` hook) — three-line helpers at the tab
  body top, type-safe via existing interfaces.
* **No always-visible Section 0 warning banner** — the consent
  modal IS the safety announcement; the tab is the operations
  console.
* **Verbose effect-style aria-labels** on consent modal buttons.
* **Mixed-batch dismiss behaviour** — no URLs queue; toast tells
  user how to retry sans Spotify.

## Deferred to follow-ups (per verdict)

* `useSpotifyConsentGate` hook wiring through HistoryPage,
  LibraryScanPage, drag-and-drop overlay, clipboard monitor —
  DownloadForm convergence point is sufficient for M9-UI.
* `help/spotify-account-risk.md` — separate help-doc PR.
* i18n for the Spotify tab + consent modal copy — alongside
  the rest of the M9 localisation work.

## Verification

* `cargo check --lib` — clean
* `cargo clippy --lib -- -D warnings` — clean
* `tsc --noEmit` — clean
* `npm run lint` — clean
* Vitest — 550 / 550 passed
@Salem874 Salem874 changed the title fix(ci): consolidated alpha-line stability — release pipeline + upstream watcher + dep bumps (supersedes #918) M9: Spotify integration backend + UI + alpha CI/dep consolidation (#101) Jun 10, 2026
@Salem874 Salem874 merged commit 590086d into alpha Jun 10, 2026
@Salem874 Salem874 deleted the fix/upstream-gamdl-watch-setup-python-sha branch June 10, 2026 22:53
Salem874 added a commit that referenced this pull request Jun 10, 2026
## Summary

* **`.gitignore`** — adds the `*~$*` pattern. Microsoft Word writes a
lock file alongside every open `.docx` document with a `~$` prefix (e.g.
`~$some-doc.docx`); these are ephemeral and never want to land in git.
Pattern matches Word lock files in any directory.
* **`help/docker-wrapper-gamdl-guide-v3.docx`** — v3 of the
Docker-wrapper GAMDL setup guide. Distributed with the repo so users on
Windows / macOS / Linux can hand the document to colleagues without
rebuilding.

## Verification

* Local `git status` clean against the cherry-picked commit (no extra
files dragged along).
* Pattern `*~$*` resolves a recurring "untracked Word lock file" noise
reported in working-tree status checks during the M9 work.

## Notes

* The commit was authored locally on 2026-06-08 and remained behind PR
#919's M9 work until that PR merged into alpha — this is the trailing
commit on its own focused branch.
Salem874 added a commit that referenced this pull request Jun 19, 2026
…Rust deps

Licence-compliance CI on this PR flagged 6 direct Rust deps that exist on alpha (from M9 Spotify integration #919, Profile Bundle #899, and the meedya-fingerprint/meedya-lyrics shared crates landing) but were never added to ACKNOWLEDGEMENTS.md.

Backfilled in alphabetical order:

| Crate | Where it shows up |
|---|---|
| aes-gcm 0.10 | Profile Bundle export passphrase encryption + credential vault |
| meedya-fingerprint (git) | Shared audio-fingerprint primitives from MeedyaSuite-core |
| meedya-lyrics (git) | Shared lyrics primitives from MeedyaSuite-core (TTML / Lyricsfile / classifier / LRC offset round-trip) |
| pbkdf2 0.12 | Password-based key derivation (paired with aes-gcm) |
| rand 0.8 | Salt + nonce derivation, retry jitter |
| rusqlite 0.31 | Library Index database + Profile Bundle manifest persistence |

Verified by running both `npm run check:acknowledgements` (set-coverage) and `npm run check:upstream-licences` (drift check against actual upstream licence strings) — both now pass with 49 Rust + 26 npm direct deps covered and zero advisories.

Required by alpha's licence-compliance CI before this PR can merge.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant