mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 09:44:29 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9157e2ecf7 | ||
|
|
51fe217e15 | ||
|
|
9e5e57f048 | ||
|
|
ecf559cd6d | ||
|
|
ad9ad229c9 | ||
|
|
9f8e1e6f61 | ||
|
|
28bfa6ae0b | ||
|
|
8297afc449 | ||
|
|
59bcf338a3 | ||
|
|
03e1c1d57e |
@@ -0,0 +1,90 @@
|
||||
name: Content packs
|
||||
|
||||
# Build & publish opt-in career venue packs as per-pack, versioned, immutable
|
||||
# releases (convention: tag `venue-<id>-v<N>`, asset `<id>-pack-v<N>.zip`),
|
||||
# then open a PR bumping venues.json url/sha256/bytes. This is automation so
|
||||
# publishing packs is never a person's manual job (SLIM-NIGHTLY item 1b).
|
||||
#
|
||||
# Immutable tags → publishing is a deliberate, versioned act, so this runs on
|
||||
# manual dispatch (not push): a media change means a new version, a human call.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
venues:
|
||||
description: "Space-separated venue ids to (re)publish, e.g. 'club arena'"
|
||||
required: true
|
||||
default: "club"
|
||||
version:
|
||||
description: "Pack version N (tag venue-<id>-vN). Bump for new media."
|
||||
required: true
|
||||
default: "1"
|
||||
|
||||
concurrency:
|
||||
group: content-packs
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Media lives in-tree today; add `lfs: true` once SLIM-NIGHTLY item 4
|
||||
# moves venue-packs/** to Git LFS.
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build & publish packs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Never interpolate dispatch inputs straight into the shell — a crafted
|
||||
# value would execute on the runner with this job's write token. Pass
|
||||
# via env, validate the formats, and use a Bash argument array.
|
||||
VENUES: ${{ github.event.inputs.venues }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^[1-9][0-9]*$ ]] || { echo "::error::version must be a positive integer"; exit 1; }
|
||||
[[ "$VENUES" =~ ^[a-z0-9][a-z0-9-]*(\ [a-z0-9][a-z0-9-]*)*$ ]] || { echo "::error::venues must be space-separated venue ids"; exit 1; }
|
||||
read -r -a venues <<< "$VENUES"
|
||||
dirs=()
|
||||
for v in "${venues[@]}"; do
|
||||
dirs+=("plugins/career/venue-packs/$v")
|
||||
done
|
||||
python tools/content_packs.py "${dirs[@]}" \
|
||||
--version "$VERSION" \
|
||||
--publish \
|
||||
--manifest /tmp/packs-manifest.json
|
||||
cat /tmp/packs-manifest.json
|
||||
|
||||
- name: Apply url/sha256/bytes to venues.json
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json, pathlib
|
||||
manifest = json.load(open("/tmp/packs-manifest.json"))
|
||||
vpath = pathlib.Path("plugins/career/venues.json")
|
||||
data = json.loads(vpath.read_text())
|
||||
for v in data["venues"]:
|
||||
m = manifest.get(v["id"])
|
||||
if m and v.get("pack"):
|
||||
v["pack"].update(url=m["url"], sha256=m["sha256"], bytes=m["bytes"])
|
||||
vpath.write_text(json.dumps(data, indent=4) + "\n")
|
||||
PY
|
||||
|
||||
- name: Open manifest-bump PR
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
commit-message: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
|
||||
title: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
|
||||
body: |
|
||||
Automated by the content-packs workflow after publishing
|
||||
`${{ github.event.inputs.venues }}` v${{ github.event.inputs.version }}
|
||||
to their `venue-<id>-v${{ github.event.inputs.version }}` releases.
|
||||
Bumps `venues.json` pack url/sha256/bytes to match the uploaded zips.
|
||||
branch: content-packs/manifest-bump
|
||||
delete-branch: true
|
||||
@@ -8,6 +8,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media
|
||||
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand
|
||||
from its release when you reach the venue (sha256-verified), keeping the
|
||||
starter `bar` venue bundled for offline play. Trims ~678 MB from the desktop
|
||||
download; an unpublished pack shows "coming soon" and plays on the standard
|
||||
stage until its release lands.
|
||||
- **Session-sync relay WebSocket — `/ws/sync/{session_id}` (#1030).** A
|
||||
deliberately dumb JSON fan-out room: a text frame received from one client is
|
||||
forwarded verbatim to every other client on the same session id; the server
|
||||
interprets nothing (message schemas are owned by consumers). Rooms are created
|
||||
on first join and garbage-collected when the last socket leaves — no history,
|
||||
no replay, no persistence, so a host that crashes and rejoins the same id
|
||||
resumes publishing to reconnecting subscribers with no server-side
|
||||
coordination. Session ids are client-generated (`[A-Za-z0-9_-]{4,64}`); DoS
|
||||
hygiene for a LAN-exposed port via frame-size (16 KB), per-room (16 sockets),
|
||||
total-room (32), and per-socket rate (120 msg/s sustained, 240 burst) caps —
|
||||
over-limit sockets are closed with a policy code and the room carries on, and
|
||||
a peer that dies — or stalls: fan-out sends are bounded by a 5 s timeout —
|
||||
mid-fan-out is dropped without disturbing delivery to the rest. `main.py`
|
||||
also caps inbound WS frames at the transport (`ws_max_size=64 KB`, down from
|
||||
uvicorn's 16 MB default) so oversized frames never materialize server-side. First consumer: splitscreen's "pop out to LAN" follower mode
|
||||
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
|
||||
frames from a host window to view-only followers on other LAN devices.
|
||||
Implementation in `lib/routers/ws_sync.py`; tests in `tests/test_ws_sync.py`.
|
||||
- **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song
|
||||
carries several drum charts, a **Drum part** selector appears beside the
|
||||
arrangement switcher (advanced settings) so a player can choose which drummer
|
||||
@@ -159,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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Session-sync relay WebSocket — /ws/sync/{session_id} (feedBack#1030).
|
||||
|
||||
A deliberately dumb fan-out room: a JSON text frame received from one client
|
||||
is forwarded verbatim to every OTHER client connected to the same session id.
|
||||
The server interprets nothing beyond the limits below — message schemas are
|
||||
owned entirely by consumers. First consumer: splitscreen's LAN follower mode
|
||||
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
|
||||
frames from a host window to view-only followers on other LAN devices.
|
||||
|
||||
Design points (full spec in the issue):
|
||||
|
||||
- Rooms are created on first join and garbage-collected when the last socket
|
||||
leaves. No history, no replay, no persistence — a late joiner simply waits
|
||||
for the next frame. Consumers that need state on join re-send it themselves
|
||||
(splitscreen answers every follower ``hello`` with a fresh ``config``).
|
||||
- That statelessness is what makes consumer crash-recovery work: a host that
|
||||
relaunches and rejoins the same session id resumes publishing to its
|
||||
reconnecting subscribers with no server-side coordination, and an idle room
|
||||
is indistinguishable from a nonexistent one.
|
||||
- ``session_id`` is client-generated and opaque (``[A-Za-z0-9_-]{4,64}``);
|
||||
consumers pick their own id policy (splitscreen uses a short typeable,
|
||||
persistent room key).
|
||||
- DoS hygiene for a port that may be LAN-exposed: frame-size cap, per-room and
|
||||
total-room caps, and a per-socket inbound token-bucket rate cap. Over-limit
|
||||
sockets are closed with a policy code; the room carries on. A peer that dies
|
||||
mid-fan-out is dropped without wedging delivery to the rest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{4,64}$")
|
||||
|
||||
# Limits. Sized generously above the first consumer's needs (splitscreen
|
||||
# publishes time frames at ~15-20 Hz to a handful of viewers) while bounding
|
||||
# what an open LAN port can be made to do. All module-level so tests (and a
|
||||
# desperate operator) can override them.
|
||||
MAX_FRAME_BYTES = 16 * 1024
|
||||
MAX_CLIENTS_PER_ROOM = 16
|
||||
MAX_ROOMS = 32
|
||||
RATE_MSGS_PER_SEC = 120.0 # sustained inbound frames per socket
|
||||
RATE_BURST = 240.0 # token-bucket burst headroom
|
||||
# A peer that stops draining its socket would leave send_text() pending
|
||||
# forever — and since publishers await the fan-out gather, one stalled peer
|
||||
# would stall every publisher's receive loop behind it. Bounding the send
|
||||
# turns the stall into an eviction through the normal failed-send drop path.
|
||||
SEND_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
# RFC 6455 close codes.
|
||||
_WS_UNSUPPORTED_DATA = 1003 # binary frame on a text-only relay
|
||||
_WS_POLICY_VIOLATION = 1008 # invalid session id / rate cap exceeded
|
||||
_WS_MSG_TOO_BIG = 1009
|
||||
_WS_TRY_AGAIN_LATER = 1013 # room or server at capacity
|
||||
|
||||
# session_id → {socket: per-socket send lock}. The lock serializes concurrent
|
||||
# fan-out sends to the same peer (two publishers relaying at once must not
|
||||
# interleave writes on a third socket's transport).
|
||||
_rooms: dict[str, dict[WebSocket, asyncio.Lock]] = {}
|
||||
|
||||
|
||||
async def _send_locked(peer: WebSocket, lock: asyncio.Lock, text: str) -> None:
|
||||
async with lock:
|
||||
await asyncio.wait_for(peer.send_text(text), timeout=SEND_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
@router.websocket("/ws/sync/{session_id}")
|
||||
async def sync_ws(websocket: WebSocket, session_id: str):
|
||||
"""Join the fan-out room *session_id*; relay every inbound text frame."""
|
||||
await websocket.accept()
|
||||
|
||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||
await websocket.close(code=_WS_POLICY_VIOLATION, reason="invalid session id")
|
||||
return
|
||||
|
||||
# Capacity checks and insertion run with no await between them, so
|
||||
# concurrent joiners on the event loop can't race past the caps.
|
||||
room = _rooms.get(session_id)
|
||||
if room is None:
|
||||
if len(_rooms) >= MAX_ROOMS:
|
||||
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="too many active sessions")
|
||||
return
|
||||
room = _rooms[session_id] = {}
|
||||
log.debug("ws_sync: room %s created", session_id)
|
||||
elif len(room) >= MAX_CLIENTS_PER_ROOM:
|
||||
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="session full")
|
||||
return
|
||||
room[websocket] = asyncio.Lock()
|
||||
|
||||
tokens = RATE_BURST
|
||||
last_refill = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
text = message.get("text")
|
||||
if text is None:
|
||||
await websocket.close(code=_WS_UNSUPPORTED_DATA, reason="text frames only")
|
||||
break
|
||||
if len(text.encode("utf-8", errors="ignore")) > MAX_FRAME_BYTES:
|
||||
await websocket.close(code=_WS_MSG_TOO_BIG, reason="frame too large")
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
tokens = min(RATE_BURST, tokens + (now - last_refill) * RATE_MSGS_PER_SEC)
|
||||
last_refill = now
|
||||
tokens -= 1.0
|
||||
if tokens < 0:
|
||||
await websocket.close(code=_WS_POLICY_VIOLATION, reason="rate cap exceeded")
|
||||
break
|
||||
|
||||
peers = [(ws, lock) for ws, lock in room.items() if ws is not websocket]
|
||||
if not peers:
|
||||
continue
|
||||
results = await asyncio.gather(
|
||||
*(_send_locked(ws, lock, text) for ws, lock in peers),
|
||||
return_exceptions=True,
|
||||
)
|
||||
# A peer that failed mid-send is dropped from the room here; its
|
||||
# own handler finishes cleanup (the finally below) when its
|
||||
# receive loop observes the disconnect.
|
||||
for (peer, _lock), result in zip(peers, results):
|
||||
if isinstance(result, Exception):
|
||||
room.pop(peer, None)
|
||||
finally:
|
||||
room.pop(websocket, None)
|
||||
# Guard against deleting a NEW room another joiner created after this
|
||||
# one emptied (only possible for a dict that is no longer ours).
|
||||
if not room and _rooms.get(session_id) is room:
|
||||
del _rooms[session_id]
|
||||
log.debug("ws_sync: room %s closed", session_id)
|
||||
@@ -44,6 +44,13 @@ def run() -> None:
|
||||
# record — including early startup messages — passes through the same
|
||||
# structured pipeline.
|
||||
log_config=None,
|
||||
# Cap inbound WebSocket frames at the transport, before uvicorn
|
||||
# materializes them in memory (its default is 16 MB). No client sends
|
||||
# large frames to this server: the highway WS receives only small
|
||||
# control messages, and the /ws/sync relay enforces its own tighter
|
||||
# 16 KB application cap (routers/ws_sync.py MAX_FRAME_BYTES) — this is
|
||||
# the defense-in-depth bound above it.
|
||||
ws_max_size=64 * 1024,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,13 @@ def _bundled(venue_id):
|
||||
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
|
||||
|
||||
|
||||
def _pack_published(pack):
|
||||
"""A remote pack is downloadable only once a publish has stamped its real
|
||||
size — the committed manifest carries a 0-byte placeholder (and an all-zero
|
||||
sha) until then, so don't offer a download that can't succeed yet."""
|
||||
return bool(pack and (pack.get("bytes") or 0) > 0)
|
||||
|
||||
|
||||
def _stars():
|
||||
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
|
||||
db = _state["meta_db"]
|
||||
@@ -664,7 +671,7 @@ def setup(app, context):
|
||||
"unlocked": stars_total >= v["star_threshold"],
|
||||
"installed": _installed(v["id"]),
|
||||
"bundled": _bundled(v["id"]),
|
||||
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
|
||||
"has_pack": _bundled(v["id"]) or _pack_published(v.get("pack")),
|
||||
"download": dl,
|
||||
})
|
||||
return {
|
||||
@@ -934,7 +941,7 @@ def setup(app, context):
|
||||
if venue is None:
|
||||
raise HTTPException(404, "Unknown venue.")
|
||||
pack = venue.get("pack")
|
||||
if not pack:
|
||||
if not _pack_published(pack):
|
||||
raise HTTPException(404, "No pack published for this venue yet.")
|
||||
stars_total, _, _ = _stars()
|
||||
if stars_total < venue["star_threshold"]:
|
||||
|
||||
@@ -17,14 +17,22 @@
|
||||
"name": "Velvet Room",
|
||||
"description": "A proper club stage. People actually came to hear you.",
|
||||
"star_threshold": 50,
|
||||
"pack": null
|
||||
"pack": {
|
||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-club-v1/club-pack-v1.zip",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"bytes": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "arena",
|
||||
"name": "Feedback Arena",
|
||||
"description": "Ten thousand seats. Try not to think about it.",
|
||||
"star_threshold": 150,
|
||||
"pack": null
|
||||
"pack": {
|
||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-arena-v1/arena-pack-v1.zip",
|
||||
"sha256": "8898c7912c84123559ecfd8b0afd3be19da9a0a4b04aa9dbb5aa1e7f4e3e1669",
|
||||
"bytes": 351284599
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -49,7 +49,7 @@ import demo_mode
|
||||
import scan
|
||||
import tailwind_rebuild
|
||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, ws_sync, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||||
from routers import tunings as tunings_router
|
||||
import enrichment
|
||||
from routers import art as art_router
|
||||
@@ -1618,6 +1618,12 @@ app.include_router(media_router.router)
|
||||
app.include_router(ws_highway.router)
|
||||
|
||||
|
||||
# ── Session-sync relay WebSocket (feedBack#1030) ─────────────────────────────
|
||||
# Dumb JSON fan-out rooms for cross-device followers (splitscreen LAN mode).
|
||||
# Implementation in lib/routers/ws_sync.py.
|
||||
app.include_router(ws_sync.router)
|
||||
|
||||
|
||||
# ── Audio serving ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -83,10 +83,25 @@ def test_download_without_published_pack_404s(client):
|
||||
|
||||
def test_download_locked_venue_403s(client, monkeypatch):
|
||||
club = career_routes._venue("club")
|
||||
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
|
||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
||||
|
||||
|
||||
def test_placeholder_pack_is_not_offered_until_published(client, monkeypatch):
|
||||
# A committed manifest carries a 0-byte placeholder until its release is
|
||||
# published. Such a pack must not be offered (has_pack False) and its
|
||||
# download must 404 — else the UI shows a button that can only fail.
|
||||
monkeypatch.setattr(career_routes, "_bundled", lambda vid: False)
|
||||
club = career_routes._venue("club")
|
||||
monkeypatch.setitem(club, "pack",
|
||||
{"url": "http://x/c.zip", "sha256": "0" * 64, "bytes": 0})
|
||||
by_id = {v["id"]: v for v in client.get("/api/plugins/career/state").json()["venues"]}
|
||||
assert by_id["club"]["has_pack"] is False # placeholder → not offered
|
||||
assert by_id["arena"]["has_pack"] is True # arena ships real bytes
|
||||
# Even forced, an unpublished pack won't start a download.
|
||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 404
|
||||
|
||||
|
||||
def test_bundled_bar_pack_is_installed_and_served(client):
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
bar = {v["id"]: v for v in state["venues"]}["bar"]
|
||||
@@ -167,9 +182,53 @@ def test_download_worker_end_to_end(client, tmp_path):
|
||||
assert "sha256" in bad["error"]
|
||||
|
||||
|
||||
def test_content_packs_build_roundtrips_through_download(client, tmp_path):
|
||||
# tools/content_packs.py must produce a zip the real career worker accepts:
|
||||
# build_pack → manifest_entry → _download_pack → installed.
|
||||
from tools import content_packs
|
||||
|
||||
src = tmp_path / "bar"
|
||||
src.mkdir()
|
||||
for s in career_routes.REQUIRED_LOOPS:
|
||||
(src / f"{s}.mp4").write_bytes(b"fake-" + s.encode())
|
||||
(src / "cheer.mp4").write_bytes(b"fake-cheer")
|
||||
(src / "manifest.json").write_text(json.dumps({
|
||||
"venue": "bar", "version": 1,
|
||||
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
|
||||
"stingers": {"cheer": "cheer.mp4"},
|
||||
}))
|
||||
out_dir = tmp_path / "packs"
|
||||
zip_path = out_dir / content_packs.pack_asset("bar", 1)
|
||||
info = content_packs.build_pack(src, zip_path)
|
||||
entry = content_packs.manifest_entry(zip_path, zip_path.resolve().as_uri())
|
||||
assert entry["sha256"] == info["sha256"] and entry["bytes"] == info["bytes"]
|
||||
|
||||
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||
career_routes._download_pack("bar", entry, progress)
|
||||
assert progress["status"] == "done", progress["error"]
|
||||
assert career_routes._installed("bar")
|
||||
|
||||
|
||||
def test_content_packs_rejects_files_the_downloader_would_refuse(tmp_path):
|
||||
# A stray file (e.g. macOS .DS_Store) must fail the build, not get published
|
||||
# and then break every client's download at _validate_pack_dir.
|
||||
from tools import content_packs
|
||||
|
||||
src = tmp_path / "bar"
|
||||
src.mkdir()
|
||||
(src / "bored.mp4").write_bytes(b"fake")
|
||||
(src / ".DS_Store").write_bytes(b"junk")
|
||||
try:
|
||||
content_packs.build_pack(src, tmp_path / "bar-pack-v1.zip")
|
||||
except ValueError as e:
|
||||
assert "downloader will reject" in str(e)
|
||||
else:
|
||||
raise AssertionError("build_pack accepted a .DS_Store the downloader rejects")
|
||||
|
||||
|
||||
def test_double_download_409s(client, monkeypatch):
|
||||
bar = career_routes._venue("bar")
|
||||
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
|
||||
# Pretend one is already running.
|
||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for the session-sync relay WebSocket (/ws/sync/{session_id}).
|
||||
|
||||
Behavior tests run against a minimal FastAPI app carrying just the router
|
||||
(fast — no full-server import); one integration test imports the real server
|
||||
to pin that the route is actually mounted there.
|
||||
|
||||
Covers the feedBack#1030 acceptance list: bidirectional fan-out, late join,
|
||||
sender never echoed, room garbage collection, and the limit closes (invalid
|
||||
session id, binary frames, frame size, room size, room count, rate cap) —
|
||||
including that one client tripping a limit doesn't disturb the others.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from routers import ws_sync
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_rooms():
|
||||
ws_sync._rooms.clear()
|
||||
yield
|
||||
ws_sync._rooms.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(ws_sync.router)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _expect_close(ws, code):
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
ws.receive_text()
|
||||
assert exc.value.code == code
|
||||
|
||||
|
||||
# ── Fan-out semantics ────────────────────────────────────────────────────────
|
||||
|
||||
def test_two_clients_relay_both_directions_and_no_echo(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM01") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM01") as b:
|
||||
a.send_text('{"type":"time","t":1.5}')
|
||||
assert b.receive_text() == '{"type":"time","t":1.5}'
|
||||
b.send_text('{"type":"hello"}')
|
||||
# A's first inbound frame is B's hello — NOT an echo of its own send.
|
||||
assert a.receive_text() == '{"type":"hello"}'
|
||||
|
||||
|
||||
def test_late_joiner_receives_subsequent_frames(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM02") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM02") as b:
|
||||
a.send_text("f1")
|
||||
assert b.receive_text() == "f1"
|
||||
with client.websocket_connect("/ws/sync/ROOM02") as c:
|
||||
a.send_text("f2")
|
||||
assert b.receive_text() == "f2"
|
||||
assert c.receive_text() == "f2"
|
||||
|
||||
|
||||
def test_rooms_are_isolated(client):
|
||||
with client.websocket_connect("/ws/sync/ROOMA1") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOMB1") as b, \
|
||||
client.websocket_connect("/ws/sync/ROOMA1") as a2:
|
||||
a.send_text("for-room-a")
|
||||
assert a2.receive_text() == "for-room-a"
|
||||
# B (other room) got nothing: prove it by relaying within B's room.
|
||||
with client.websocket_connect("/ws/sync/ROOMB1") as b2:
|
||||
b2.send_text("for-room-b")
|
||||
assert b.receive_text() == "for-room-b"
|
||||
|
||||
|
||||
def test_client_disconnect_does_not_disrupt_remaining(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM03") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM03") as b:
|
||||
with client.websocket_connect("/ws/sync/ROOM03") as c:
|
||||
a.send_text("before")
|
||||
assert b.receive_text() == "before"
|
||||
assert c.receive_text() == "before"
|
||||
# C is gone; relay between A and B continues.
|
||||
a.send_text("after")
|
||||
assert b.receive_text() == "after"
|
||||
|
||||
|
||||
def test_room_garbage_collected_when_last_client_leaves(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM04") as a:
|
||||
with client.websocket_connect("/ws/sync/ROOM04") as b:
|
||||
a.send_text("x")
|
||||
assert b.receive_text() == "x"
|
||||
assert "ROOM04" in ws_sync._rooms
|
||||
assert "ROOM04" not in ws_sync._rooms
|
||||
assert ws_sync._rooms == {}
|
||||
|
||||
|
||||
# ── Limit enforcement ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("bad_id", ["abc", "x" * 65, "has space", "bad$id", "nope!"])
|
||||
def test_invalid_session_id_closed_with_policy_code(client, bad_id):
|
||||
with client.websocket_connect(f"/ws/sync/{bad_id}") as ws:
|
||||
_expect_close(ws, 1008)
|
||||
assert ws_sync._rooms == {}
|
||||
|
||||
|
||||
def test_binary_frame_closes_with_unsupported_data(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM05") as ws:
|
||||
ws.send_bytes(b"\x00\x01")
|
||||
_expect_close(ws, 1003)
|
||||
|
||||
|
||||
def test_oversized_frame_closes_sender_only(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM06") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM06") as b, \
|
||||
client.websocket_connect("/ws/sync/ROOM06") as c:
|
||||
a.send_text("x" * (ws_sync.MAX_FRAME_BYTES + 1))
|
||||
_expect_close(a, 1009)
|
||||
# The room carries on without A.
|
||||
b.send_text("still-alive")
|
||||
assert c.receive_text() == "still-alive"
|
||||
|
||||
|
||||
def test_room_client_cap(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "MAX_CLIENTS_PER_ROOM", 2)
|
||||
with client.websocket_connect("/ws/sync/ROOM07") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM07") as b, \
|
||||
client.websocket_connect("/ws/sync/ROOM07") as c:
|
||||
_expect_close(c, 1013)
|
||||
a.send_text("two-is-fine")
|
||||
assert b.receive_text() == "two-is-fine"
|
||||
|
||||
|
||||
def test_total_room_cap(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "MAX_ROOMS", 1)
|
||||
with client.websocket_connect("/ws/sync/ROOM08"):
|
||||
with client.websocket_connect("/ws/sync/ROOM09") as overflow:
|
||||
_expect_close(overflow, 1013)
|
||||
# Joining the EXISTING room is still fine at the room cap.
|
||||
with client.websocket_connect("/ws/sync/ROOM08"):
|
||||
pass
|
||||
|
||||
|
||||
def test_rate_cap_closes_flooding_sender(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "RATE_BURST", 3.0)
|
||||
monkeypatch.setattr(ws_sync, "RATE_MSGS_PER_SEC", 0.0)
|
||||
with client.websocket_connect("/ws/sync/ROOM10") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM10") as b:
|
||||
for i in range(3):
|
||||
a.send_text(f"burst-{i}")
|
||||
for i in range(3):
|
||||
assert b.receive_text() == f"burst-{i}"
|
||||
a.send_text("one-too-many")
|
||||
_expect_close(a, 1008)
|
||||
# The over-limit frame was dropped, not relayed, and B lives on.
|
||||
with client.websocket_connect("/ws/sync/ROOM10") as c:
|
||||
c.send_text("fresh-socket")
|
||||
assert b.receive_text() == "fresh-socket"
|
||||
|
||||
|
||||
class _StalledPeer:
|
||||
"""A fake room member whose send never completes (peer stopped draining)."""
|
||||
|
||||
async def send_text(self, text):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def test_stalled_peer_is_evicted_and_healthy_peers_still_receive(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "SEND_TIMEOUT_SECONDS", 0.2)
|
||||
with client.websocket_connect("/ws/sync/ROOM11") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM11") as b:
|
||||
# Wait for both handlers to have registered in the room, then inject
|
||||
# the stalled peer directly (a real stalled TCP peer isn't
|
||||
# constructible under TestClient).
|
||||
deadline = time.monotonic() + 2.0
|
||||
while len(ws_sync._rooms.get("ROOM11", {})) < 2:
|
||||
assert time.monotonic() < deadline, "room never filled"
|
||||
time.sleep(0.01)
|
||||
stalled = _StalledPeer()
|
||||
ws_sync._rooms["ROOM11"][stalled] = asyncio.Lock()
|
||||
|
||||
# Healthy delivery is not blocked behind the stalled peer, and by the
|
||||
# time a second frame has round-tripped, the first fan-out's timeout
|
||||
# has fired and evicted it.
|
||||
a.send_text("f1")
|
||||
assert b.receive_text() == "f1"
|
||||
a.send_text("f2")
|
||||
assert b.receive_text() == "f2"
|
||||
assert stalled not in ws_sync._rooms["ROOM11"]
|
||||
|
||||
|
||||
def test_main_run_caps_uvicorn_ws_max_size():
|
||||
"""main.py must bound inbound WS frames at the transport (uvicorn defaults
|
||||
to 16 MB, which would let a client materialize frames far past the relay's
|
||||
16 KB application cap before the handler ever sees them)."""
|
||||
import unittest.mock
|
||||
|
||||
import main
|
||||
|
||||
with (
|
||||
unittest.mock.patch("logging_setup.configure_logging"),
|
||||
unittest.mock.patch("uvicorn.run") as mock_run,
|
||||
):
|
||||
main.run()
|
||||
|
||||
kwargs = mock_run.call_args.kwargs
|
||||
assert kwargs.get("ws_max_size") == 64 * 1024
|
||||
assert kwargs["ws_max_size"] >= ws_sync.MAX_FRAME_BYTES
|
||||
|
||||
|
||||
# ── Real-app integration ─────────────────────────────────────────────────────
|
||||
|
||||
def test_route_mounted_on_real_server(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
|
||||
with TestClient(server.app) as client:
|
||||
with client.websocket_connect("/ws/sync/REALAPP") as a, \
|
||||
client.websocket_connect("/ws/sync/REALAPP") as b:
|
||||
a.send_text('{"type":"time","t":0}')
|
||||
assert b.receive_text() == '{"type":"time","t":0}'
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build & publish opt-in content packs (career venue media, rig VST slices).
|
||||
|
||||
Flat-zips a pack directory, sha256s it, and emits the ``{url, sha256, bytes}``
|
||||
block that the career and rig_builder download paths consume
|
||||
(``plugins/career/routes.py`` ``_download_pack``). Two modes:
|
||||
|
||||
--local <dir> write zips + a file:// manifest (dev/CI/tests; no network)
|
||||
--publish create/upload each pack's per-pack release; emit release URLs
|
||||
|
||||
The zip is flat (files at the archive root) to satisfy career's zip-slip guard
|
||||
(``PACK_FILENAME_RE``) and ``_validate_pack_dir``. This module is the reusable
|
||||
core the content-packs CI workflow calls, so building packs is automation —
|
||||
never a person's manual job.
|
||||
|
||||
Run ``python tools/content_packs.py --selfcheck`` for the built-in round-trip.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = "got-feedBack/feedBack" # where the content-packs release lives (public)
|
||||
|
||||
# Must mirror career's download-time whitelist (plugins/career/routes.py
|
||||
# PACK_FILENAME_RE). If the builder packs a name the downloader rejects (e.g. a
|
||||
# stray .DS_Store), the published pack fails _validate_pack_dir for every client.
|
||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||
|
||||
|
||||
def build_pack(src_dir: Path, out_zip: Path) -> dict:
|
||||
"""Flat-zip every file directly under src_dir; return {sha256, bytes}.
|
||||
|
||||
Only regular files at the top level are included (venue packs are flat).
|
||||
Subdirectories are skipped — a nested tree would trip career's zip-slip
|
||||
guard on download anyway.
|
||||
|
||||
The build is REPRODUCIBLE: identical file contents always yield a
|
||||
byte-identical zip (fixed name order, fixed mtime, fixed permissions,
|
||||
ZIP_STORED). So a sha256 computed on any machine matches the zip the CI
|
||||
workflow or another contributor produces — anyone can precompute the
|
||||
manifest values without having to be the one who uploads the asset.
|
||||
"""
|
||||
files = sorted((p for p in src_dir.iterdir() if p.is_file()),
|
||||
key=lambda p: p.name)
|
||||
if not files:
|
||||
raise ValueError(f"no files to pack in {src_dir}")
|
||||
bad = [p.name for p in files if not PACK_FILENAME_RE.fullmatch(p.name)]
|
||||
if bad:
|
||||
raise ValueError(
|
||||
f"{src_dir}: files the downloader will reject: {bad} "
|
||||
f"(allowed: {PACK_FILENAME_RE.pattern})")
|
||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
|
||||
# ZIP_STORED: the media (mp4/mp3) and .vst3 binaries are already
|
||||
# compressed; deflating just burns CPU for ~0 gain.
|
||||
for p in files:
|
||||
# Fixed mtime (the zip epoch, 1980-01-01) + fixed perms so the
|
||||
# bytes don't depend on the checkout's file timestamps.
|
||||
info = zipfile.ZipInfo(p.name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
# Pin create_system: ZipInfo defaults it from the host OS (0 on
|
||||
# Windows, 3 on Unix), which would otherwise make the same pack
|
||||
# hash differently across runners. 3 = Unix.
|
||||
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)}
|
||||
|
||||
|
||||
# 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,
|
||||
"sha256": hashlib.sha256(out_zip.read_bytes()).hexdigest(),
|
||||
"bytes": out_zip.stat().st_size}
|
||||
|
||||
|
||||
# Per-pack, versioned, immutable release convention (matches what the team
|
||||
# already published, e.g. tag `venue-arena-v1` / asset `arena-pack-v1.zip`).
|
||||
def pack_tag(pack_id: str, version: int) -> str:
|
||||
return f"venue-{pack_id}-v{version}"
|
||||
|
||||
|
||||
def pack_asset(pack_id: str, version: int) -> str:
|
||||
return f"{pack_id}-pack-v{version}.zip"
|
||||
|
||||
|
||||
def pack_url(pack_id: str, version: int, repo: str = REPO) -> str:
|
||||
return (f"https://github.com/{repo}/releases/download/"
|
||||
f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}")
|
||||
|
||||
|
||||
# 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.
|
||||
"""
|
||||
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", 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
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("src", nargs="*", type=Path,
|
||||
help="pack source dirs (e.g. plugins/career/venue-packs/club)")
|
||||
ap.add_argument("--version", type=int, default=1,
|
||||
help="pack version (tag venue-<id>-v<N>); default 1")
|
||||
ap.add_argument("--local", type=Path, metavar="DIR",
|
||||
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")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.selfcheck:
|
||||
return _selfcheck()
|
||||
if not args.src or (not args.local and not args.publish):
|
||||
ap.error("need one or more src dirs and either --local or --publish")
|
||||
|
||||
out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs"
|
||||
manifest = {}
|
||||
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:
|
||||
args.manifest.write_text(out + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
|
||||
def _selfcheck() -> int:
|
||||
"""Build a pack and confirm build_pack/manifest_entry agree on the digest."""
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
td = Path(td)
|
||||
src = td / "bar"
|
||||
src.mkdir()
|
||||
(src / "manifest.json").write_text('{"venue":"bar"}')
|
||||
(src / "bored.mp4").write_bytes(b"\x00fake-video")
|
||||
zip_path = td / pack_asset("bar", 1)
|
||||
info = build_pack(src, zip_path)
|
||||
# Reproducible: a second build (into a different path) is byte-identical.
|
||||
info2 = build_pack(src, td / "again.zip")
|
||||
assert info2["sha256"] == info["sha256"], "build is not reproducible"
|
||||
entry = manifest_entry(zip_path, pack_url("bar", 1))
|
||||
assert entry["sha256"] == info["sha256"], "digest mismatch"
|
||||
assert entry["bytes"] == info["bytes"]
|
||||
assert entry["url"] == (
|
||||
f"https://github.com/{REPO}/releases/download/venue-bar-v1/bar-pack-v1.zip")
|
||||
# Round-trip: the zip must be flat (names == basenames).
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user