M9: Spotify integration backend + UI + alpha CI/dep consolidation (#101)#919
Merged
Conversation
…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.
7 tasks
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)
Closed
3 tasks
…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
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.
This was referenced Jun 15, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
849ae6d3release.ymlsame-channelPREV_TAG+ alpha/beta/RCgit pushrebase-on-conflict retry (5 attempts, 0–4 s jitter) — fixes thebody is too longfailure that hit alpha.17/19/20 and the alpha.16↔17 lost-cut raced1fe7216,87502f96upstream-gamdl-watch.ymlSHA rotation + daily cadence (superseded by M9-1's generalisedupstream-engine-watch.yml)2760812a3f70a0c3i18next26.3.0→26.3.1,configparser3.1.0→3.2.0,chrono0.4.44→0.4.45,log0.4.30→0.4.328681743fM9 Spotify integration — backend
ac730ab3votify_service+VotifyOptions35-field CLI table + realVotifyFeaturegates (DesktopAacAndMp4Flac1.9.5+,UpcTag1.9.7+)16f4f9d12b72f418AntiBanSettingsmodel,compute_playback_throttle_delay/compute_inter_track_delay(pure functions),DailyCapCounterwith local-midnight rollover, 4 IPC commands,DispatchGateOutcomeenum6065fc20start_downloadacceptsopen.spotify.com, runs the dispatch gate at IPC entry,SpotifySettingssession-type fields,increment_counterhelper5096fe1eMissingSpotifyDll+MissingWvdoutcomes),check_session_artifactsvalidator,.wvdkept first-class for non-Windows FLACb3358d82M9 Spotify integration — frontend
54938230RiskPill3-tier visual cue. Hybrid design from a 2-agent design tournament + adversarial judgeVerification
cargo check --lib— cleancargo clippy --lib -- -D warnings— cleancargo test --lib— 1411 passed, 1 ignored, 0 failed (+~80 new tests across the M9 layers)tsc --noEmit— cleannpm run lint— cleanMulti-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
playback_speed_throttle_enabled = true— the flagship mitigation can't be silently regressed.daily_download_cap = 100default;0is the explicit "unlimited" sentinel and requires consent + dev access to set.Deferred to follow-ups
compute_playback_throttle_delaybetween tracks)help/spotify-account-risk.mddocOutstanding before merge