mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 15:28:31 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a979eafe6 | ||
|
|
7ef13fc8c7 | ||
|
|
ba31fd9150 |
@@ -0,0 +1,50 @@
|
|||||||
|
"""feedpak — open song-package format loader (canonical module name).
|
||||||
|
|
||||||
|
The format was renamed `sloppak` → `feedpak`. The implementation currently still
|
||||||
|
lives in :mod:`sloppak`; this module is the **canonical name going forward** and
|
||||||
|
re-exports that public API unchanged, so new code can do::
|
||||||
|
|
||||||
|
import feedpak
|
||||||
|
loaded = feedpak.load_song(filename, dlc_root, cache_root)
|
||||||
|
|
||||||
|
Both `.feedpak` and `.sloppak` packs are accepted everywhere (the legacy
|
||||||
|
extension and the `sloppak` module name are kept as permanent deprecated
|
||||||
|
aliases). The authoritative on-disk format spec is published at
|
||||||
|
https://github.com/got-feedback/feedback-feedpak-spec.
|
||||||
|
|
||||||
|
When the internal rename lands, the implementation can move into this module and
|
||||||
|
`sloppak.py` becomes the thin re-export shim instead — importers of `feedpak`
|
||||||
|
will not need to change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sloppak import ( # noqa: F401 (re-export)
|
||||||
|
PACK_SUFFIXES,
|
||||||
|
LoadedFeedpak,
|
||||||
|
LoadedSloppak,
|
||||||
|
extract_meta,
|
||||||
|
get_cached_source_dir,
|
||||||
|
is_feedpak,
|
||||||
|
is_pack,
|
||||||
|
is_sloppak,
|
||||||
|
load_manifest,
|
||||||
|
load_song,
|
||||||
|
read_feedpak_version,
|
||||||
|
resolve_source_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PACK_SUFFIXES",
|
||||||
|
"LoadedFeedpak",
|
||||||
|
"LoadedSloppak",
|
||||||
|
"extract_meta",
|
||||||
|
"get_cached_source_dir",
|
||||||
|
"is_feedpak",
|
||||||
|
"is_pack",
|
||||||
|
"is_sloppak",
|
||||||
|
"load_manifest",
|
||||||
|
"load_song",
|
||||||
|
"read_feedpak_version",
|
||||||
|
"resolve_source_dir",
|
||||||
|
]
|
||||||
+65
-12
@@ -1,14 +1,23 @@
|
|||||||
"""Sloppak — open song format loader.
|
"""Open song-package format loader (feedpak; legacy name: sloppak).
|
||||||
|
|
||||||
A `.sloppak` is an open, hand-editable song package. It exists in two
|
A pack is an open, hand-editable song package. It exists in two
|
||||||
interchangeable forms:
|
interchangeable forms:
|
||||||
|
|
||||||
1. **Zip archive** — a `.sloppak` file containing a `manifest.yaml`,
|
1. **Zip archive** — a `.feedpak` (or legacy `.sloppak`) file containing a
|
||||||
arrangement JSONs, stem OGGs, optional cover/lyrics. Distribution form.
|
`manifest.yaml`, arrangement JSONs, stem OGGs, optional cover/lyrics.
|
||||||
2. **Directory** — a directory whose name ends in `.sloppak/` containing the
|
Distribution form.
|
||||||
same files. Authoring form.
|
2. **Directory** — a directory whose name ends in `.feedpak/` (or legacy
|
||||||
|
`.sloppak/`) containing the same files. Authoring form.
|
||||||
|
|
||||||
See the format spec in the project's sloppak plan for the full layout.
|
The format is now published as **feedpak** at
|
||||||
|
https://github.com/got-feedback/feedpak-spec — that spec is the
|
||||||
|
authoritative reference for the on-disk layout.
|
||||||
|
|
||||||
|
**Naming / deprecation.** The format was renamed `sloppak` → `feedpak`. This
|
||||||
|
module keeps the `sloppak` names as **permanent deprecated aliases** so existing
|
||||||
|
libraries and importers never break: both `.feedpak` and `.sloppak` are accepted
|
||||||
|
everywhere, and `lib/feedpak.py` re-exports this module's public API under the
|
||||||
|
canonical name. Prefer `feedpak` in new code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -47,9 +56,28 @@ import notation as notation_mod
|
|||||||
|
|
||||||
# ── Format detection ──────────────────────────────────────────────────────────
|
# ── Format detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Accepted pack extensions. `.feedpak` is canonical; `.sloppak` is the permanent
|
||||||
|
# legacy alias (see module docstring). Both forms are byte-identical packs.
|
||||||
|
PACK_SUFFIXES = (".feedpak", ".sloppak")
|
||||||
|
|
||||||
|
|
||||||
|
def is_pack(path: Path) -> bool:
|
||||||
|
"""True if path looks like a feedpak/sloppak pack (zip file or directory)."""
|
||||||
|
return path.name.lower().endswith(PACK_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_feedpak(path: Path) -> bool:
|
||||||
|
"""Canonical name for :func:`is_pack` — accepts both `.feedpak` and `.sloppak`."""
|
||||||
|
return is_pack(path)
|
||||||
|
|
||||||
|
|
||||||
def is_sloppak(path: Path) -> bool:
|
def is_sloppak(path: Path) -> bool:
|
||||||
"""True if path looks like a sloppak (zip file or directory)."""
|
"""Deprecated alias for :func:`is_pack`, kept so existing callers keep working.
|
||||||
return path.name.lower().endswith(".sloppak")
|
|
||||||
|
Despite the name it accepts **both** `.feedpak` and `.sloppak` (the format
|
||||||
|
was renamed; the legacy extension is still read). Prefer :func:`is_feedpak`.
|
||||||
|
"""
|
||||||
|
return is_pack(path)
|
||||||
|
|
||||||
|
|
||||||
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
|
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
|
||||||
@@ -217,12 +245,29 @@ def _read_manifest_from_zip(zip_path: Path) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def load_manifest(path: Path) -> dict:
|
def load_manifest(path: Path) -> dict:
|
||||||
"""Return the parsed manifest dict for a sloppak (dir or zip)."""
|
"""Return the parsed manifest dict for a pack (dir or zip)."""
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
return _read_manifest(path)
|
return _read_manifest(path)
|
||||||
return _read_manifest_from_zip(path)
|
return _read_manifest_from_zip(path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_feedpak_version(manifest: dict) -> str | None:
|
||||||
|
"""Return the manifest's declared `feedpak_version` (a semver string), or None.
|
||||||
|
|
||||||
|
The feedpak spec (§4.1) makes the key optional and says an absent value is
|
||||||
|
treated as ``"1.0.0"``; callers that want that default can apply it. We return
|
||||||
|
the declared value verbatim (or None) so the distinction "declared vs implicit"
|
||||||
|
is preserved. Non-string values are ignored with a warning.
|
||||||
|
"""
|
||||||
|
raw = manifest.get("feedpak_version")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
return raw.strip()
|
||||||
|
log.warning("feedpak: ignoring non-string feedpak_version %r", raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_COVER_MEDIA_TYPES = {
|
_COVER_MEDIA_TYPES = {
|
||||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
".png": "image/png", ".webp": "image/webp",
|
".png": "image/png", ".webp": "image/webp",
|
||||||
@@ -797,13 +842,12 @@ def load_song(
|
|||||||
"events": clean_events,
|
"events": clean_events,
|
||||||
}
|
}
|
||||||
|
|
||||||
_fpv = manifest.get("feedpak_version")
|
|
||||||
return LoadedSloppak(
|
return LoadedSloppak(
|
||||||
song=song,
|
song=song,
|
||||||
stems=stems,
|
stems=stems,
|
||||||
source_dir=source_dir,
|
source_dir=source_dir,
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
feedpak_version=_fpv if isinstance(_fpv, str) and _fpv else None,
|
feedpak_version=read_feedpak_version(manifest),
|
||||||
drum_tab=drum_tab_data,
|
drum_tab=drum_tab_data,
|
||||||
song_timeline=song_timeline_data,
|
song_timeline=song_timeline_data,
|
||||||
tempos=tempos_data,
|
tempos=tempos_data,
|
||||||
@@ -884,4 +928,13 @@ def extract_meta(path: Path) -> dict:
|
|||||||
"stem_count": stem_count,
|
"stem_count": stem_count,
|
||||||
# slopsmith#129: per-stem filter needs the id list, not just count.
|
# slopsmith#129: per-stem filter needs the id list, not just count.
|
||||||
"stem_ids": stem_ids,
|
"stem_ids": stem_ids,
|
||||||
|
# Declared feedpak format version (semver string) or None when absent.
|
||||||
|
"feedpak_version": read_feedpak_version(manifest),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Canonical-name aliases ────────────────────────────────────────────────────
|
||||||
|
# The format was renamed sloppak → feedpak. `LoadedFeedpak` is the canonical
|
||||||
|
# name for the loaded-pack dataclass; the `LoadedSloppak` name above stays as a
|
||||||
|
# permanent deprecated alias. `lib/feedpak.py` re-exports the public API.
|
||||||
|
LoadedFeedpak = LoadedSloppak
|
||||||
|
|||||||
@@ -3355,6 +3355,10 @@ async def startup_events():
|
|||||||
"unregister_library_provider": unregister_library_provider,
|
"unregister_library_provider": unregister_library_provider,
|
||||||
"register_tuning_provider": register_tuning_provider,
|
"register_tuning_provider": register_tuning_provider,
|
||||||
"unregister_tuning_provider": unregister_tuning_provider,
|
"unregister_tuning_provider": unregister_tuning_provider,
|
||||||
|
# `get_feedpak_cache_dir` is the canonical name (format renamed
|
||||||
|
# sloppak → feedpak); `get_sloppak_cache_dir` stays as a permanent
|
||||||
|
# deprecated alias so existing plugins keep working. Same cache dir.
|
||||||
|
"get_feedpak_cache_dir": lambda: SLOPPAK_CACHE_DIR,
|
||||||
"get_sloppak_cache_dir": lambda: SLOPPAK_CACHE_DIR,
|
"get_sloppak_cache_dir": lambda: SLOPPAK_CACHE_DIR,
|
||||||
"register_demo_janitor_hook": register_demo_janitor_hook,
|
"register_demo_janitor_hook": register_demo_janitor_hook,
|
||||||
# Unified XP service (fee[dB]ack v0.3.0). Plugins that award XP
|
# Unified XP service (fee[dB]ack v0.3.0). Plugins that award XP
|
||||||
@@ -3827,7 +3831,7 @@ def trigger_full_rescan():
|
|||||||
|
|
||||||
# ── Song upload ───────────────────────────────────────────────────────────────
|
# ── Song upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_ALLOWED_SONG_EXTS = {".sloppak"}
|
_ALLOWED_SONG_EXTS = {".feedpak", ".sloppak"} # .sloppak is the legacy alias
|
||||||
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems
|
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems
|
||||||
# Per-request batch cap. Lets a user drop a whole album of sloppaks at once
|
# Per-request batch cap. Lets a user drop a whole album of sloppaks at once
|
||||||
# without giving a hostile client a 1000-file DoS surface via Starlette's
|
# without giving a hostile client a 1000-file DoS surface via Starlette's
|
||||||
@@ -4058,7 +4062,7 @@ async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) ->
|
|||||||
suffix = Path(base).suffix.lower()
|
suffix = Path(base).suffix.lower()
|
||||||
if suffix not in _ALLOWED_SONG_EXTS:
|
if suffix not in _ALLOWED_SONG_EXTS:
|
||||||
return {"status": "error", "filename": base,
|
return {"status": "error", "filename": base,
|
||||||
"error": "Only .sloppak files are accepted"}
|
"error": "Only .feedpak (or legacy .sloppak) files are accepted"}
|
||||||
|
|
||||||
dest = dlc / base
|
dest = dlc / base
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
@@ -4271,7 +4275,7 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
|
|||||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||||
stems_has: str = "", stems_lacks: str = "",
|
stems_has: str = "", stems_lacks: str = "",
|
||||||
has_lyrics: str = "", tunings: str = "") -> dict:
|
has_lyrics: str = "", tunings: str = "") -> dict:
|
||||||
fmt = format if format in ("archive", "sloppak", "loose") else ""
|
fmt = format if format in ("archive", "sloppak", "feedpak", "loose") else ""
|
||||||
return {
|
return {
|
||||||
"q": q,
|
"q": q,
|
||||||
"favorites_only": bool(favorites),
|
"favorites_only": bool(favorites),
|
||||||
|
|||||||
+2
-2
@@ -4152,10 +4152,10 @@ async function uploadSongs(fileList) {
|
|||||||
const files = [];
|
const files = [];
|
||||||
for (const f of all) {
|
for (const f of all) {
|
||||||
const lower = f.name.toLowerCase();
|
const lower = f.name.toLowerCase();
|
||||||
if (lower.endsWith('.sloppak')) {
|
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
|
||||||
files.push(f);
|
files.push(f);
|
||||||
} else {
|
} else {
|
||||||
failures.push(`${f.name}: only .sloppak accepted`);
|
failures.push(`${f.name}: only .feedpak (or legacy .sloppak) accepted`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||||
level so it stays reachable regardless of which screen is active. -->
|
level so it stays reachable regardless of which screen is active. -->
|
||||||
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||||
|
|
||||||
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
|
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
|
||||||
<div id="home" class="screen active">
|
<div id="home" class="screen active">
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Coverage for the additive sloppak → feedpak rename (back-compat foundation).
|
||||||
|
|
||||||
|
The format was renamed `sloppak` → `feedpak`. This phase is purely additive: both
|
||||||
|
the `.feedpak` and legacy `.sloppak` extensions must load, the new `feedpak`
|
||||||
|
module must re-export the loader, the `feedpak_version` manifest key must be read,
|
||||||
|
and every legacy `sloppak`-named symbol must keep working. Nothing sloppak breaks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import sloppak as sloppak_mod
|
||||||
|
import feedpak as feedpak_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _build(root: Path, suffix: str, manifest_extras: dict | None = None) -> Path:
|
||||||
|
"""Build a minimal directory-form pack with the given extension."""
|
||||||
|
pak = root / f"{root.name}{suffix}"
|
||||||
|
pak.mkdir()
|
||||||
|
arr_dir = pak / "arrangements"
|
||||||
|
arr_dir.mkdir()
|
||||||
|
(arr_dir / "lead.json").write_text(json.dumps({
|
||||||
|
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||||
|
"notes": [], "chords": [], "anchors": [], "handshapes": [], "templates": [],
|
||||||
|
}))
|
||||||
|
manifest = {
|
||||||
|
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
|
||||||
|
"duration": 10.0,
|
||||||
|
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||||
|
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||||
|
}
|
||||||
|
manifest.update(manifest_extras or {})
|
||||||
|
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||||
|
return pak
|
||||||
|
|
||||||
|
|
||||||
|
def _load(mod, pak: Path, tmp_path: Path):
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
cache.mkdir()
|
||||||
|
return mod.load_song(pak.name, pak.parent, cache)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Both extensions are detected ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("suffix", [".feedpak", ".sloppak"])
|
||||||
|
def test_detection_accepts_both_extensions(tmp_path: Path, suffix: str):
|
||||||
|
pak = tmp_path / f"song{suffix}"
|
||||||
|
assert sloppak_mod.is_pack(pak)
|
||||||
|
assert sloppak_mod.is_feedpak(pak)
|
||||||
|
assert sloppak_mod.is_sloppak(pak) # deprecated alias still accepts .feedpak
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_rejects_other_extensions(tmp_path: Path):
|
||||||
|
assert not sloppak_mod.is_pack(tmp_path / "song.zip")
|
||||||
|
assert not sloppak_mod.is_pack(tmp_path / "song.mp3")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Both extensions load, via both module names ───────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("suffix", [".feedpak", ".sloppak"])
|
||||||
|
def test_load_via_sloppak_module(tmp_path: Path, suffix: str):
|
||||||
|
pak = _build(tmp_path, suffix)
|
||||||
|
loaded = _load(sloppak_mod, pak, tmp_path)
|
||||||
|
assert loaded.song.title == "Test"
|
||||||
|
assert loaded.stems[0]["id"] == "full"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("suffix", [".feedpak", ".sloppak"])
|
||||||
|
def test_load_via_feedpak_module(tmp_path: Path, suffix: str):
|
||||||
|
"""The canonical `feedpak` module re-exports the loader and loads both forms."""
|
||||||
|
pak = _build(tmp_path, suffix)
|
||||||
|
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||||
|
assert loaded.song.title == "Test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedpak_module_reexports_match_sloppak():
|
||||||
|
for name in ("load_song", "load_manifest", "extract_meta", "resolve_source_dir",
|
||||||
|
"is_pack", "is_feedpak", "is_sloppak", "read_feedpak_version"):
|
||||||
|
assert getattr(feedpak_mod, name) is getattr(sloppak_mod, name)
|
||||||
|
assert feedpak_mod.LoadedFeedpak is sloppak_mod.LoadedSloppak
|
||||||
|
|
||||||
|
|
||||||
|
# ── feedpak_version is read ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_feedpak_version_read_when_present(tmp_path: Path):
|
||||||
|
pak = _build(tmp_path, ".feedpak", {"feedpak_version": "1.0.0"})
|
||||||
|
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||||
|
assert loaded.feedpak_version == "1.0.0"
|
||||||
|
assert feedpak_mod.extract_meta(pak)["feedpak_version"] == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedpak_version_none_when_absent(tmp_path: Path):
|
||||||
|
pak = _build(tmp_path, ".feedpak")
|
||||||
|
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||||
|
assert loaded.feedpak_version is None
|
||||||
|
assert feedpak_mod.extract_meta(pak)["feedpak_version"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedpak_version_ignores_non_string(tmp_path: Path):
|
||||||
|
pak = _build(tmp_path, ".feedpak", {"feedpak_version": 1})
|
||||||
|
loaded = _load(feedpak_mod, pak, tmp_path)
|
||||||
|
assert loaded.feedpak_version is None
|
||||||
Reference in New Issue
Block a user