Merge remote-tracking branch 'origin/main' into feat/feedpak-jsonc

Signed-off-by: topkoa <topkoa@gmail.com>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
topkoa
2026-07-01 03:27:38 -04:00
340 changed files with 24260 additions and 2876 deletions
+46 -4
View File
@@ -21,13 +21,19 @@ import zipfile
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger("slopsmith.lib.sloppak")
log = logging.getLogger("feedBack.lib.sloppak")
# The feedpak format version this build targets / writes (manifest
# `feedpak_version`, a semver string per spec §4). Readers tolerate any version
# (additive/MINOR compatibility); writers stamp this.
FEEDPAK_VERSION = "1.2.0"
# Package suffixes. The format is byte-identical regardless of suffix; `.feedpak`
# is the current write extension, `.sloppak` the legacy one we still read.
FEEDPAK_EXT = ".feedpak"
SLOPPAK_EXT = ".sloppak"
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
import yaml
from jsonc import load_json
@@ -48,8 +54,12 @@ import notation as notation_mod
# ── Format detection ──────────────────────────────────────────────────────────
def is_sloppak(path: Path) -> bool:
"""True if path looks like a sloppak (zip file or directory)."""
return path.name.lower().endswith(".sloppak")
"""True if path looks like a song package (zip file or directory).
Accepts both the current `.feedpak` suffix and the legacy `.sloppak` one —
same on-disk format, either form.
"""
return path.name.lower().endswith(SONG_EXTS)
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
@@ -357,6 +367,14 @@ class LoadedSloppak:
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
# absent so indexing by song.arrangements index is safe.
arrangement_ids: list[str | None] = field(default_factory=list)
# Manifest-relative path to the single full-mix audio file, taken from the
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
# pre-separation mixdown that exists alongside the per-instrument `stems`.
# None when the key is absent, points outside source_dir, or the file is
# missing on disk. Served to the front-end via the highway WS as
# `original_audio_url`; the stems plugin uses it to play the untouched mix
# when every stem slider is at unity (and the separate stems otherwise).
original_audio: str | None = None
def load_song(
@@ -798,6 +816,29 @@ def load_song(
}
_fpv = manifest.get("feedpak_version")
# Optional full-mix audio — manifest `original_audio:` key. The single
# pre-separation mixdown that ships alongside the per-instrument stems.
# Same permissive, path-traversal-guarded posture as drum_tab above: a
# missing/escaping/absent file simply leaves the full mix unavailable (the
# player falls back to the separate stems) rather than aborting the load.
# We store the manifest-relative string so server.py can build its URL the
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
original_audio_data: str | None = None
original_audio_rel = manifest.get("original_audio")
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
rel = original_audio_rel.strip()
try:
oa_path = (source_dir / rel).resolve()
oa_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
oa_path = None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
oa_path = None
if oa_path is not None and oa_path.is_file():
original_audio_data = rel
return LoadedSloppak(
song=song,
stems=stems,
@@ -811,6 +852,7 @@ def load_song(
keys=keys_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
original_audio=original_audio_data,
)
@@ -882,6 +924,6 @@ def extract_meta(path: Path) -> dict:
"arrangements": arrangements,
"has_lyrics": has_lyrics,
"stem_count": stem_count,
# slopsmith#129: per-stem filter needs the id list, not just count.
# feedBack#129: per-stem filter needs the id list, not just count.
"stem_ids": stem_ids,
}