Compare commits

..
Author SHA1 Message Date
Kyle 9157e2ecf7 Remove hover-preview; delegate to song_preview plugin
Folder Library now uses the standard data-fn/data-v3-play markup so the song_preview plugin handles hover-previews, the same way it does for grid and list views. Removes ~200 lines of preview-specific code: dedicated audio element, indicator rendering, toolbar toggle, and backend manifest checking. The preview experience is now unified across all library surfaces.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle 51fe217e15 Remove outdated screen.js caching note
Remove the developer note about manually bumping plugin.json version when changing screen.js

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle 9e5e57f048 Use manifest preview flag to avoid hover 404s
Expose a has_preview boolean from pack manifests and use it to gate hover previews. backend: plugins/folder_library/routes.py now (optionally) reads sloppak.load_manifest to set m['has_preview'] (guarded so plugin still loads without sloppak). frontend: plugins/folder_library/screen.js skips preview requests when song.has_preview is false and removes the HEAD-probe + previewMissing cache. docs: plugins/folder_library/CLAUDE.md updated to document has_preview and the preview behavior. This prevents unnecessary HEAD/audio 404s and console noise.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:15 -04:00
Kyle ecf559cd6d Refactor folder_library preview to delegate to song_preview
Remove audio member resolution from folder_library and delegate hover-preview audio to the canonical song_preview plugin endpoint. This separates concerns: folder_library now only builds the preview URL with the filename, while song_preview handles manifest resolution (preview: key, stem fallback) and Range support.

Changes:
- Remove _audio_member() resolver and audio_member field from pack metadata
- Update _previewUrl() to point to /api/plugins/song_preview/audio?file=<filename>
- Add _previewMissing cache to avoid re-requesting packs with no preview
- Add HEAD probe to check preview availability before playing
- Add test coverage for _previewUrl with special character encoding

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:14 -04:00
Kyle ad9ad229c9 Folder Library: enable hover preview by default
Make the hover-preview feature opt-out instead of opt-in (defaults to on). Increase dwell delay from 500ms to 800ms to prevent accidental triggers while clicking/dragging. Replace 4-bar equalizer indicator with 9-bar waveform with staggered animation and fade-in entrance. Add guards to prevent preview during drag operations. Update button styling and all user-facing docs to reflect new defaults.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:14 -04:00
Kyle 9f8e1e6f61 Resolve in-pack audio on backend
Move audio member selection logic from frontend to backend. The frontend was probing multiple candidate audio files with error/retry logic, often resulting in 404s. Now the backend resolves the best available audio file (preview.ogg, stems/full.ogg, or any stem) during metadata extraction and includes it in song metadata as `audio_member`. The frontend makes a single, guaranteed request instead of multiple probes. Simplifies the preview flow and improves reliability.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:13 -04:00
Kyle 28bfa6ae0b Add preview-on-hover to Folder Library
Implement optional audio preview when hovering over songs in the Folders view. Features include:
- Toolbar toggle button (off by default) to enable/disable hover preview
- ~0.5s dwell delay to avoid accidental audio playback while scrolling
- Dedicated <audio> element (never touches main player)
- Equalizer indicator animation overlay on song art while playing
- Per-surface localStorage persistence
- Automatic fallback through preview.ogg → stems/full.ogg → stems/audio.mp3

Bump Folder Library from 1.8.0 to 1.9.0. Remove 'auto play on hover' from roadmap as it's now implemented.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
2026-07-23 00:51:13 -04:00
8297afc449 feat(tools): per-platform VST3 slicing for rig content packs (#1025)
ship-ci / ci (push) Waiting to run
Rebased onto merged main (was stacked on #1023/#1024, whose venue work is
now in main) so it no longer carries a stale content_packs.py that would
revert 1023's build_pack fixes.

- build_vst_pack: slice a fat .vst3 tree to one platform (keep its binary
  dir + shared bundle files, drop the two foreign platform dirs and src/
  build trees). Pins create_system=3 like build_pack — without it the same
  tree hashes differently on a Windows runner (native .vst3 are built there),
  breaking the precomputable-hash guarantee exactly where it matters.
- Publish wiring: 'python tools/content_packs.py <vst-root> --vst --version N
  --publish' builds+uploads vst-<plat>-vN releases for mac/win/linux and emits
  a platform-keyed {url,sha256,bytes} manifest — the shape rig_builder's
  data/vst_packs.json consumes. publish() refactored onto a shared
  _publish_release helper (venue behaviour unchanged).
- Tests: slice keeps target+shared/drops foreign, per-platform binary,
  reproducibility, unknown-platform reject, and a simulated-win32 guard that
  fails if the create_system pin is dropped. selfcheck covers the VST path.

Original build_vst_pack by Matthew Harris Glover; reworked for the create_system
fix, publish wiring, and rebase.

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:01:51 +02:00
8 changed files with 359 additions and 22 deletions
+5
View File
@@ -183,6 +183,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
at `default: off`, drops the key); the fallback and the aliases are removed once
they are migrated (#945).
- **Folder Library previews on hover, like the grid and list views.** The Folders
view's cards and rows now carry the standard `data-fn` / `data-v3-play` markup,
so the existing **Song Preview** plugin previews them on hover exactly like the
other views (same audio, same behaviour) — Folder Library ships no preview code
of its own.
### Added
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
+11 -2
View File
@@ -160,6 +160,7 @@ Each song object (built by `_meta()`):
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
- Hover-preview isn't resolved here at all — the cards just carry `data-fn`/`data-v3-play` markup and the `song_preview` plugin handles it (see Preview on Hover).
### extract_meta returns arrangements/stems as objects, not strings
@@ -329,13 +330,21 @@ Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTM
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
- **Enter confirms** — submits, equivalent to OK
## Preview on Hover
The Folder Library does **not** implement hover-preview itself — the `song_preview` plugin does, for the whole app. Its hover loop finds song elements by the selector `#v3-songs [data-fn]` (with a `[data-v3-play]` playable surface) and its `MutationObserver` watches the `#v3-songs` subtree — and the folder view (`#lib-folder-tree`) renders **inside** `#v3-songs`. So all folder_library has to do is give each card/row the standard markup:
- **`_songCard`** — `card.dataset.fn = song.filename` (raw filename) + `data-v3-play` on the art wrap (the surface `song_preview` overlays its indicator on).
- **`_songRow`** — `row.dataset.fn = song.filename` + `data-v3-play` on the thumb.
`song_preview` then previews folder view exactly like the grid/list — same audio (its `/audio` endpoint), same availability/404 handling, same indicator — with **zero preview code here**. If you change the card/row structure, keep `data-fn` (raw, not URL-encoded) and a `[data-v3-play]` descendant, or `song_preview` will stop recognising the cards.
## Roadmap
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache.
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache. Hover-preview is provided by the `song_preview` plugin via the `data-fn` markup above (not implemented here).
Not yet implemented, in rough priority order:
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
- **Bulk move** — multi-select songs and move them all at once.
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
+4 -1
View File
@@ -30,6 +30,7 @@ A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC s
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
- **Album art** — pulls art automatically for every song in both views
- **One-click playback** — click any song to start playing immediately
- **Preview song on hover** — hover a song to hear a quick preview of its audio (powered by the Song Preview plugin, the same way the grid and list views work)
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
- **Folder management** — create, rename, and delete folders without leaving the plugin
@@ -54,6 +55,7 @@ Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`),
| Switch to grid view | Click the grid icon in the toolbar |
| Switch to list view | Click the list icon in the toolbar |
| Play a song | Click any song row or card |
| Preview song on hover | Hover a song — the Song Preview plugin plays a quick clip (same as the grid/list views) |
| Sort songs | Use the sort dropdown in the toolbar |
| Toggle sort direction | Click the arrow button next to the sort dropdown |
| Open filters | Click the filter icon in the toolbar |
@@ -80,7 +82,8 @@ Folder Library started life as a standalone plugin with its own version line, bu
## Roadmap
- [ ] Auto play song on hover (with an on/off toggle)
- [ ] Compatibility with core settings — respect Accessibility → Interface size
- [ ] Core parity — Metadata, Unmatched, and Upload should work in the folder view too
- [ ] Bulk move — select multiple songs and move them at once
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "folder_library",
"name": "Folder Library",
"version": "1.8.0",
"version": "1.9.0",
"bundled": true,
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
"screen": "screen.html",
+14 -4
View File
@@ -735,10 +735,11 @@ function createFolderSurface(cfg) {
var card = document.createElement('div');
card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105';
card.style.background = '#1a1d2e';
card.dataset.filename = song.filename;
card.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds cards by this
var artWrap = document.createElement('div');
artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;';
artWrap.setAttribute('data-v3-play', ''); // the playable surface song_preview overlays its indicator on
var img = document.createElement('img');
img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;';
img.alt = ''; img.loading = 'lazy';
@@ -804,10 +805,11 @@ function createFolderSurface(cfg) {
function _songRow(song, folderName) {
var row = document.createElement('div');
row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100';
row.dataset.filename = song.filename;
row.dataset.fn = song.filename; // data-fn (raw): song_preview's hover loop finds rows by this
var thumb = document.createElement('div');
thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;';
thumb.setAttribute('data-v3-play', ''); // marks the row previewable for song_preview
var tImg = document.createElement('img');
tImg.loading = 'lazy';
tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art';
@@ -1707,8 +1709,16 @@ function createFolderSurface(cfg) {
init: _init,
onScreenChanged: _onScreenChanged,
render: _render,
// Pure window arithmetic, exposed for tests (no DOM needed).
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
// Helpers exposed for tests. visibleWindow is pure; songCard/songRow
// need a DOM (the tests supply a minimal element mock) and pin the
// song_preview integration markup (data-fn + a data-v3-play surface).
__test: {
visibleWindow: _visibleWindow,
VIRTUAL_MIN: VIRTUAL_MIN,
VIRTUAL_BUFFER: VIRTUAL_BUFFER,
songCard: _songCard,
songRow: _songRow,
},
};
}
@@ -0,0 +1,116 @@
// song_preview integration markup (feedBack — Folders view hover preview).
//
// The Folder Library does NOT implement hover-preview itself. It relies on the
// separate `song_preview` plugin, exactly like the grid and list views. That
// plugin's host adapter finds previewable elements with the selector
// `#v3-songs [data-fn]` and requires each to contain a `[data-v3-play]`
// descendant (the surface it overlays its indicator on), reading the raw
// filename from `data-fn`.
//
// So the ENTIRE contract Folder Library owns is: every song card and row it
// renders must carry `data-fn` (raw filename) and expose a `[data-v3-play]`
// surface. If a refactor drops either, folder cards silently stop previewing
// while grid/list keep working — a regression that's invisible without a live
// song_preview install. These tests pin the markup so that can't happen.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
// A minimal DOM element mock — just enough for _songCard / _songRow to run.
// No jsdom in this repo (see virtual_list.test.js); the element tracks the few
// things the contract cares about: dataset, attributes, and a child tree that
// querySelector('[data-v3-play]') can walk.
function makeEl(tag) {
const attrs = {};
const el = {
tagName: String(tag || '').toUpperCase(),
style: {}, // supports .cssText and arbitrary props
dataset: {},
className: '',
children: [],
parentNode: null,
addEventListener() {},
removeEventListener() {},
setAttribute(k, v) { attrs[k] = String(v); },
getAttribute(k) { return k in attrs ? attrs[k] : null; },
hasAttribute(k) { return k in attrs; },
appendChild(child) { el.children.push(child); if (child) child.parentNode = el; return child; },
classList: { add() {}, remove() {}, contains() { return false; }, toggle() {} },
remove() {},
// Only the '[data-v3-play]'-style attribute selector is needed.
querySelector(sel) {
const attr = sel.replace(/^\[|\]$/g, '');
const stack = el.children.slice();
while (stack.length) {
const n = stack.shift();
if (n && n.hasAttribute && n.hasAttribute(attr)) return n;
if (n && n.children) stack.push(...n.children);
}
return null;
},
};
return el;
}
function load() {
const window = {
console,
document: {
readyState: 'complete',
addEventListener() {},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
createElement(tag) { return makeEl(tag); },
},
addEventListener() {},
localStorage: { getItem() { return null; }, setItem() {} },
performance: { now: () => 0 },
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame() { return 0; },
cancelAnimationFrame() {},
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
innerHeight: 800,
};
window.window = window;
window.globalThis = window;
const ctx = vm.createContext(window);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
return window.folderLibrary.__test;
}
const { songCard, songRow } = load();
// A raw filename with a subfolder + spaces — the kind of value song_preview
// URL-encodes downstream, so it must reach data-fn verbatim, not pre-encoded.
const FILENAME = 'Some Artist/A Song.sloppak';
const SONG = { filename: FILENAME, title: 'A Song', artist: 'Some Artist' };
test('song_preview helpers are exposed for the markup contract', () => {
assert.equal(typeof songCard, 'function');
assert.equal(typeof songRow, 'function');
});
test('grid card carries data-fn (raw) and a data-v3-play surface', () => {
const card = songCard(SONG, 'Unsorted');
assert.equal(card.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
assert.ok(card.querySelector('[data-v3-play]'), 'card must contain a [data-v3-play] surface');
});
test('list row carries data-fn (raw) and a data-v3-play surface', () => {
const row = songRow(SONG, 'Unsorted');
assert.equal(row.dataset.fn, FILENAME, 'data-fn must be the raw, un-encoded filename');
assert.ok(row.querySelector('[data-v3-play]'), 'row must contain a [data-v3-play] surface');
});
test('card renders without depending on any optional song metadata', () => {
// song_preview only needs filename; the card must build from a bare song
// (no duration/arrangements/stems/lyrics/tuning/year) without throwing.
assert.doesNotThrow(() => songCard({ filename: FILENAME }, 'Unsorted'));
assert.doesNotThrow(() => songRow({ filename: FILENAME }, 'Unsorted'));
});
+91
View File
@@ -0,0 +1,91 @@
"""VST-pack slicing (tools/content_packs.build_vst_pack): each platform pack
keeps only its own binaries + the shared bundle files, drops the rest, and is
reproducible."""
import zipfile
from pathlib import Path
from tools import content_packs
def _fake_vst_tree(root: Path):
# One fat .vst3 with all three platform binaries + shared files, plus a
# src/ build tree that must never ship.
c = root / "amps" / "Foo.vst3" / "Contents"
(c / "MacOS").mkdir(parents=True)
(c / "x86_64-win").mkdir(parents=True)
(c / "x86_64-linux").mkdir(parents=True)
(c / "Resources").mkdir(parents=True)
(c / "MacOS" / "Foo").write_bytes(b"mac-binary")
(c / "x86_64-win" / "Foo.vst3").write_bytes(b"win-binary")
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux-binary")
(c / "Info.plist").write_bytes(b"<plist/>")
(c / "Resources" / "moduleinfo.json").write_bytes(b"{}")
(root / "src" / "build").mkdir(parents=True)
(root / "src" / "build" / "junk.o").write_bytes(b"objfile")
def _names(zip_path):
with zipfile.ZipFile(zip_path) as zf:
return set(zf.namelist())
def test_slice_keeps_target_platform_and_shared_drops_foreign(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
content_packs.build_vst_pack(root, tmp_path / "mac.zip", "mac")
names = _names(tmp_path / "mac.zip")
base = "amps/Foo.vst3/Contents"
assert f"{base}/MacOS/Foo" in names # target binary kept
assert f"{base}/Info.plist" in names # shared kept
assert f"{base}/Resources/moduleinfo.json" in names # shared kept
assert f"{base}/x86_64-win/Foo.vst3" not in names # foreign dropped
assert f"{base}/x86_64-linux/Foo.so" not in names # foreign dropped
assert not any(n.startswith("src/") for n in names) # build trees never ship
def test_each_platform_gets_its_own_binary(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
wanted = {"mac": "MacOS/Foo", "win": "x86_64-win/Foo.vst3", "linux": "x86_64-linux/Foo.so"}
for plat, rel in wanted.items():
content_packs.build_vst_pack(root, tmp_path / f"{plat}.zip", plat)
names = _names(tmp_path / f"{plat}.zip")
assert f"amps/Foo.vst3/Contents/{rel}" in names
others = [v for k, v in wanted.items() if k != plat]
for o in others:
assert f"amps/Foo.vst3/Contents/{o}" not in names
def test_slice_is_reproducible(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
a = content_packs.build_vst_pack(root, tmp_path / "a.zip", "linux")
b = content_packs.build_vst_pack(root, tmp_path / "b.zip", "linux")
assert a == b and a["sha256"]
def test_slice_pins_create_system_for_cross_runner_reproducibility(tmp_path, monkeypatch):
# ZipInfo defaults create_system from the host OS (0 on Windows, 3 on Unix),
# and it lands in the central directory — so without an explicit pin the same
# tree hashes differently on a Windows runner, breaking the precomputable-hash
# guarantee exactly where it matters (native .vst3 are built on Windows). A
# same-machine reproducibility test can't catch that; simulate win32 and
# assert the pin forces 3 regardless.
monkeypatch.setattr(zipfile.sys, "platform", "win32")
root = tmp_path / "vst"
_fake_vst_tree(root)
content_packs.build_vst_pack(root, tmp_path / "w.zip", "linux")
with zipfile.ZipFile(tmp_path / "w.zip") as zf:
assert all(i.create_system == 3 for i in zf.infolist())
def test_unknown_platform_rejected(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
try:
content_packs.build_vst_pack(root, tmp_path / "x.zip", "bsd")
except ValueError as e:
assert "unknown platform" in str(e)
else:
raise AssertionError("build_vst_pack accepted an unknown platform")
+117 -14
View File
@@ -74,6 +74,52 @@ def build_pack(src_dir: Path, out_zip: Path) -> dict:
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
# rig_builder ships "fat" .vst3 bundles carrying all three platforms inside
# Contents/. A pack for one platform keeps that platform's binary dir + the
# shared bundle files, and drops the other two.
VST_PLATFORM_DIRS = {"mac": "MacOS", "win": "x86_64-win", "linux": "x86_64-linux"}
def build_vst_pack(vst_root: Path, out_zip: Path, platform: str) -> dict:
"""Reproducibly zip the VST tree keeping only `platform`'s binaries.
Slices each fat .vst3: everything is kept except the two foreign platform
dirs (MacOS / x86_64-win / x86_64-linux) and the vst/src build trees. Arc
names are relative to vst_root so the download endpoint extracts straight
into <plugin>/vst/. Same reproducible-build guarantees as build_pack.
"""
if platform not in VST_PLATFORM_DIRS:
raise ValueError(f"unknown platform {platform!r} (want mac/win/linux)")
foreign = set(VST_PLATFORM_DIRS.values()) - {VST_PLATFORM_DIRS[platform]}
files = []
for p in sorted(vst_root.rglob("*"), key=lambda q: q.as_posix()):
if not p.is_file():
continue
rel = p.relative_to(vst_root)
if rel.parts and rel.parts[0] == "src": # skip C++/JUCE build trees
continue
if set(rel.parts) & foreign: # drop foreign-platform binaries
continue
files.append((p, rel))
if not files:
raise ValueError(f"no VST files to pack for {platform} in {vst_root}")
out_zip.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
for p, rel in files:
info = zipfile.ZipInfo(rel.as_posix(), date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED
# Pin create_system like build_pack: ZipInfo defaults it from the
# host OS (0 on Windows, 3 on Unix), which would otherwise make the
# same pack hash differently across runners. VST packs are the most
# likely to be built on Windows (native .vst3), so without this pin
# the precomputable-hash guarantee breaks exactly where it's needed.
info.create_system = 3
info.external_attr = 0o644 << 16
zf.writestr(info, p.read_bytes())
data = out_zip.read_bytes()
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
def manifest_entry(out_zip: Path, url: str) -> dict:
"""Pack info as the download-path expects it: {url, sha256, bytes}."""
return {"url": url,
@@ -96,25 +142,47 @@ def pack_url(pack_id: str, version: int, repo: str = REPO) -> str:
f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}")
def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None:
# VST packs use the same immutable per-pack convention, keyed by platform:
# tag `vst-<plat>-v<N>`, asset `vst-<plat>-pack-v<N>.zip`. The manifest they
# emit is keyed by platform (mac/win/linux) — the shape the rig_builder plugin's
# data/vst_packs.json consumes.
def vst_tag(platform: str, version: int) -> str:
return f"vst-{platform}-v{version}"
def vst_asset(platform: str, version: int) -> str:
return f"vst-{platform}-pack-v{version}.zip"
def vst_url(platform: str, version: int, repo: str = REPO) -> str:
return (f"https://github.com/{repo}/releases/download/"
f"{vst_tag(platform, version)}/{vst_asset(platform, version)}")
def _publish_release(tag: str, zip_path: Path, title: str, notes: str,
repo: str = REPO) -> None:
"""Create the per-pack release if missing, then upload the versioned zip.
Tags are immutable: a media change means a new version (v1 → v2), never a
re-upload — so no --clobber. gh errors if the asset already exists, which is
the right guard against overwriting a published, referenced pack.
"""
tag = pack_tag(pack_id, version)
if subprocess.run(["gh", "release", "view", tag, "--repo", repo],
capture_output=True).returncode != 0:
subprocess.run(
["gh", "release", "create", tag, "--repo", repo, "--latest=false",
"--title", f"{pack_id.capitalize()} venue pack v{version}",
"--notes", "Opt-in career venue pack. Not a code release."],
"--title", title, "--notes", notes],
check=True)
subprocess.run(
["gh", "release", "upload", tag, str(zip_path), "--repo", repo], check=True)
def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None:
_publish_release(pack_tag(pack_id, version), zip_path,
f"{pack_id.capitalize()} venue pack v{version}",
"Opt-in career venue pack. Not a code release.", repo)
def _pack_id(src_dir: Path) -> str:
return src_dir.name
@@ -129,6 +197,10 @@ def main(argv=None) -> int:
help="write zips here + a file:// manifest.json; no upload")
ap.add_argument("--publish", action="store_true",
help="create/upload the per-pack release; emit release URLs")
ap.add_argument("--vst", action="store_true",
help="slice one rig VST root (src[0]) into per-platform "
"vst-<plat>-v<N> packs; manifest keyed by platform "
"(the shape rig_builder's data/vst_packs.json wants)")
ap.add_argument("--manifest", type=Path,
help="write the {id: {url,sha256,bytes}} map here (default: stdout)")
ap.add_argument("--selfcheck", action="store_true", help="run the round-trip demo and exit")
@@ -141,16 +213,30 @@ def main(argv=None) -> int:
out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs"
manifest = {}
for src in args.src:
pid = _pack_id(src)
zip_path = out_dir / pack_asset(pid, args.version)
build_pack(src, zip_path)
if args.publish:
publish(pid, args.version, zip_path)
url = pack_url(pid, args.version)
else:
url = (out_dir.resolve() / zip_path.name).as_uri()
manifest[pid] = manifest_entry(zip_path, url)
if args.vst:
vst_root = args.src[0]
for plat in VST_PLATFORM_DIRS:
zip_path = out_dir / vst_asset(plat, args.version)
build_vst_pack(vst_root, zip_path, plat)
if args.publish:
_publish_release(vst_tag(plat, args.version), zip_path,
f"Rig VST pack ({plat}) v{args.version}",
"Opt-in per-platform rig VST pack. Not a code release.")
url = vst_url(plat, args.version)
else:
url = (out_dir.resolve() / zip_path.name).as_uri()
manifest[plat] = manifest_entry(zip_path, url)
else:
for src in args.src:
pid = _pack_id(src)
zip_path = out_dir / pack_asset(pid, args.version)
build_pack(src, zip_path)
if args.publish:
publish(pid, args.version, zip_path)
url = pack_url(pid, args.version)
else:
url = (out_dir.resolve() / zip_path.name).as_uri()
manifest[pid] = manifest_entry(zip_path, url)
out = json.dumps(manifest, indent=2)
if args.manifest:
@@ -183,6 +269,23 @@ def _selfcheck() -> int:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
assert set(names) == {"manifest.json", "bored.mp4"}, names
# VST slice: keep target platform + shared, drop foreign, reproducible.
c = td / "vst" / "Foo.vst3" / "Contents"
for d in ("MacOS", "x86_64-win", "x86_64-linux", "Resources"):
(c / d).mkdir(parents=True)
(c / "MacOS" / "Foo").write_bytes(b"mac")
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux")
(c / "Info.plist").write_bytes(b"<plist/>")
vzip = td / vst_asset("linux", 1)
vinfo = build_vst_pack(td / "vst", vzip, "linux")
assert vinfo == build_vst_pack(td / "vst", td / "v2.zip", "linux"), \
"vst slice is not reproducible"
with zipfile.ZipFile(vzip) as zf:
vnames = set(zf.namelist())
assert "Foo.vst3/Contents/x86_64-linux/Foo.so" in vnames
assert "Foo.vst3/Contents/Info.plist" in vnames
assert not any("MacOS" in n for n in vnames), vnames
print("content_packs selfcheck: ok")
return 0