feat: save as .feedpak; discover and load both .feedpak and .sloppak (#553)

* feat: save songs as .feedpak; discover and load both .feedpak and .sloppak

The open song format was renamed sloppak -> feedpak (public spec lives in
the feedback-feedpak-spec repo), but the server still wrote and recognized
only `.sloppak`. The two are byte-identical on disk.

Read both suffixes everywhere songs are discovered, uploaded, and loaded;
writing the new `.feedpak` suffix is handled in the editor plugin repo. Keep
the internal `format` tag `sloppak` so existing feature gates (stems, drums,
keys) are untouched, matching the "internal rename not landed yet" stance.

- lib/sloppak.py: add FEEDPAK_EXT / SLOPPAK_EXT / SONG_EXTS; is_sloppak()
  now matches either suffix (covers all 7 callers).
- server.py: union scan glob over SONG_EXTS; widen loose-folder exclusion,
  settings DLC count, upload gate (_ALLOWED_SONG_EXTS) and zip-magic check;
  refresh user-facing messages to .feedpak.
- static: library format filter relabeled Sloppak -> Feedpak (value stays
  sloppak, matches both); badge text SLOPPAK -> FEEDPAK in v2 + v3;
  filename-suffix detection and upload drag-drop filter accept both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* test: cover .feedpak/.sloppak dual-suffix support

Add tests/test_feedpak_extension.py pinning the four paths PR #553
widened so a refactor can't drop .sloppak back-compat or stop
accepting .feedpak:

- is_sloppak / SONG_EXTS suffix detection (file + dir form, case-insensitive)
- _background_scan discovery glob unions over both suffixes
- POST /api/songs/upload accepts both, rejects wrong suffix + non-zip
- save_settings DLC count includes both suffixes

19 tests, all passing; reuses the existing scan_module / TestClient /
isolate_logging fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
K. O. A. 2026-06-22 14:47:56 -04:00 committed by GitHub
parent a07edd9971
commit 32127bc70b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 285 additions and 26 deletions

View File

@ -29,6 +29,12 @@ log = logging.getLogger("slopsmith.lib.sloppak")
# (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 safepath import safe_join
@ -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) ──────────────

View File

@ -3230,8 +3230,10 @@ def _background_scan():
# path for playback.
def _is_excluded_from_library(p: Path) -> bool:
return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts
# Sloppaks: match both file (zip) and directory form by suffix.
sloppaks = [f for f in sorted(dlc.rglob("*.sloppak"))
# Sloppaks: match both file (zip) and directory form, across both the
# `.feedpak` and legacy `.sloppak` suffixes.
_cands = sorted(p for ext in sloppak_mod.SONG_EXTS for p in dlc.rglob(f"*{ext}"))
sloppaks = [f for f in _cands
if sloppak_mod.is_sloppak(f)
and not _is_excluded_from_library(f)]
@ -3249,7 +3251,7 @@ def _background_scan():
if _is_excluded_from_library(wem):
continue
d = wem.parent
if d in sloppak_dirs or d.name.lower().endswith(".sloppak"):
if d in sloppak_dirs or d.name.lower().endswith(sloppak_mod.SONG_EXTS):
continue
if d not in seen_loose and loosefolder_mod.is_loose_song(d):
loose_songs.append(d)
@ -3933,7 +3935,7 @@ def trigger_full_rescan():
# ── Song upload ───────────────────────────────────────────────────────────────
_ALLOWED_SONG_EXTS = {".sloppak"}
_ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS)
_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
# without giving a hostile client a 1000-file DoS surface via Starlette's
@ -4164,7 +4166,7 @@ async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) ->
suffix = Path(base).suffix.lower()
if suffix not in _ALLOWED_SONG_EXTS:
return {"status": "error", "filename": base,
"error": "Only .sloppak files are accepted"}
"error": "Only .feedpak files are accepted"}
dest = dlc / base
if dest.exists():
@ -4222,10 +4224,10 @@ async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) ->
if bytes_read == 0:
error_result = {"status": "error", "filename": base,
"error": "Empty upload — file is 0 bytes"}
elif suffix == ".sloppak":
elif suffix in _ALLOWED_SONG_EXTS:
if head[:2] != b"PK":
error_result = {"status": "error", "filename": base,
"error": "Not a valid sloppak file (expected zip archive)"}
"error": "Not a valid feedpak file (expected zip archive)"}
else:
# ZIP magic alone admits any renamed zip — verify the sloppak
# loader can actually parse a manifest.yaml inside. Without
@ -5447,8 +5449,9 @@ def save_settings(data: dict):
else:
if Path(dlc_path).is_dir():
updates["dlc_dir"] = dlc_path
count = sum(1 for f in Path(dlc_path).iterdir() if f.suffix == ".sloppak")
messages.append(f"DLC folder: {count} sloppak files found")
count = sum(1 for f in Path(dlc_path).iterdir()
if f.suffix.lower() in sloppak_mod.SONG_EXTS)
messages.append(f"DLC folder: {count} song files found")
else:
return {"error": f"DLC directory not found: {dlc_path}"}

View File

@ -1918,7 +1918,7 @@ function formatBadge(fmt, stemCount) {
return `<span class="fmt-badge absolute top-2 right-2 px-1.5 py-0.5 rounded text-[10px] font-bold bg-purple-900/80 text-purple-200 border border-purple-700">STEMS</span>`;
}
if (fmt === 'sloppak') {
return `<span class="fmt-badge absolute top-2 right-2 px-1.5 py-0.5 rounded text-[10px] font-bold bg-green-900/80 text-green-200 border border-green-700">SLOPPAK</span>`;
return `<span class="fmt-badge absolute top-2 right-2 px-1.5 py-0.5 rounded text-[10px] font-bold bg-green-900/80 text-green-200 border border-green-700">FEEDPAK</span>`;
}
if (fmt === 'loose') {
return `<span class="fmt-badge absolute top-2 right-2 px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-900/80 text-amber-200 border border-amber-700">FOLDER</span>`;
@ -1931,7 +1931,7 @@ function formatBadgeInline(fmt, stemCount) {
return `<span class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-purple-900/60 text-purple-300">STEMS</span>`;
}
if (fmt === 'sloppak') {
return `<span class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-green-900/60 text-green-300">SLOPPAK</span>`;
return `<span class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-green-900/60 text-green-300">FEEDPAK</span>`;
}
if (fmt === 'loose') {
return `<span class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-900/60 text-amber-300">FOLDER</span>`;
@ -4154,10 +4154,10 @@ async function uploadSongs(fileList) {
const files = [];
for (const f of all) {
const lower = f.name.toLowerCase();
if (lower.endsWith('.sloppak')) {
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
files.push(f);
} else {
failures.push(`${f.name}: only .sloppak accepted`);
failures.push(`${f.name}: only .feedpak accepted`);
}
}
if (files.length === 0) {

View File

@ -120,7 +120,7 @@
<select id="lib-format" onchange="sortLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Filter by format">
<option value="">All formats</option>
<option value="sloppak">Sloppak</option>
<option value="sloppak">Feedpak</option>
<option value="loose">Folder</option>
</select>
<!-- Tree controls -->

View File

@ -58,15 +58,15 @@
let f = ((song && song.format) || '').toLowerCase();
if (!f) {
const fn = ((song && song.filename) || '').toLowerCase();
f = fn.endsWith('.sloppak') ? 'sloppak' : '';
f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : '';
}
return f === 'sloppak' ? 'SLOPPAK' : f === 'loose' ? 'FOLDER' : '';
return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : '';
}
// Corner badge for art-thumbnail cards (sloppak accented, others muted).
function fmtBadge(song) {
const l = fmtName(song);
if (!l) return '';
const c = l === 'SLOPPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
}
// Inline pill for the hero (Pick/Continue) card, where the art is text-overlaid
@ -74,7 +74,7 @@
function fmtTag(song) {
const l = fmtName(song);
if (!l) return '';
const c = l === 'SLOPPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim';
const c = l === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim';
return '<span class="' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
}

View File

@ -223,7 +223,7 @@
<select id="lib-format" onchange="sortLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Filter by format">
<option value="">All formats</option>
<option value="sloppak">Sloppak</option>
<option value="sloppak">Feedpak</option>
<option value="loose">Folder</option>
</select>
<!-- Tree controls -->

View File

@ -22,7 +22,7 @@
['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
['year', 'Year (oldest)'], ['tuning', 'Tuning'],
];
const FORMATS = [['', 'All formats'], ['sloppak', 'Sloppak'], ['loose', 'Folder']];
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other'];
const PAGE_SIZE = 24;
@ -314,15 +314,15 @@
let f = (song.format || '').toLowerCase();
if (!f) {
const fn = (song.filename || '').toLowerCase();
f = fn.endsWith('.sloppak') ? 'sloppak' : '';
f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : '';
}
return f === 'sloppak' ? 'SLOPPAK' : f === 'loose' ? 'FOLDER' : '';
return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : '';
}
// Corner badge for art-based cards (sloppak accented, others muted).
function fmtBadge(song) {
const l = fmtLabel(song);
if (!l) return '';
const c = l === 'SLOPPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
}
@ -674,7 +674,7 @@
'<div class="flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
(fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'SLOPPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
(fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
(state.accuracy[k] != null ? '<span class="text-xs font-bold ' + (state.accuracy[k] >= 0.9 ? 'text-fb-good' : state.accuracy[k] >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(state.accuracy[k] * 100) + '%</span>' : '') +
'<button data-fav class="opacity-0 group-hover:opacity-100 px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
'</div>'); }).join('') + '</div>').join('') + '</div></details>').join('');

View File

@ -0,0 +1,246 @@
"""Coverage for the `.sloppak → .feedpak` dual-suffix support (PR #553).
The package format is byte-identical regardless of suffix: `.feedpak` is the
current write extension, `.sloppak` the legacy one we still read. These tests
pin the four behaviours the PR widened so a future refactor can't silently drop
back-compat for `.sloppak` libraries or stop accepting the new `.feedpak`:
1. ``sloppak.is_sloppak`` / ``SONG_EXTS`` suffix detection (unit).
2. ``_background_scan`` discovery glob finds both suffixes (DLC scan).
3. ``POST /api/songs/upload`` gate accepts both, rejects others (endpoint).
4. ``save_settings`` DLC count counts both suffixes (settings handler).
"""
from __future__ import annotations
import importlib
import io
import sys
import zipfile
from pathlib import Path
import pytest
import yaml
import sloppak as sloppak_mod
from sloppak import FEEDPAK_EXT, SLOPPAK_EXT, SONG_EXTS
# ── helpers ──────────────────────────────────────────────────────────────────
def _feedpak_zip_bytes(manifest: dict | None = None) -> bytes:
"""A minimal valid package zip: a `manifest.yaml` that parses to a dict.
The upload gate verifies more than ZIP magic it runs
``sloppak.load_manifest`` to reject any renamed zip without a parseable
top-level manifest mapping, so the bytes must contain a real one.
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("manifest.yaml",
yaml.safe_dump(manifest or {"title": "T", "artist": "A"}))
return buf.getvalue()
# ── 1. is_sloppak / SONG_EXTS (unit) ─────────────────────────────────────────
def test_song_exts_are_both_suffixes_lowercase():
assert FEEDPAK_EXT == ".feedpak"
assert SLOPPAK_EXT == ".sloppak"
# `.feedpak` first — it is the write/discovery-preferred extension; both
# entries must be lowercase because is_sloppak relies on `.lower()` + a
# tuple-suffix match.
assert SONG_EXTS == (FEEDPAK_EXT, SLOPPAK_EXT)
assert all(e == e.lower() for e in SONG_EXTS)
@pytest.mark.parametrize("name", [
"song.feedpak", # current write suffix
"song.sloppak", # legacy suffix, still read
"My Song_p.feedpak", # editor create-mode names land as *_p.feedpak
"dir-form.feedpak", # directory-form bundle (suffix-only check)
"dir-form.sloppak",
"SONG.FEEDPAK", # case-insensitive
"Song.SlopPak",
])
def test_is_sloppak_accepts_both_suffixes(name):
assert sloppak_mod.is_sloppak(Path("/dlc") / name) is True
@pytest.mark.parametrize("name", [
"song.zip", # a renamed zip is not a package by suffix
"song.txt",
"song.feedpak.bak",
"feedpak", # bare word, no dot-suffix
"noext",
])
def test_is_sloppak_rejects_other_suffixes(name):
assert sloppak_mod.is_sloppak(Path("/dlc") / name) is False
# ── 2. _background_scan discovery glob (DLC scan) ────────────────────────────
@pytest.fixture()
def scan_server(tmp_path, monkeypatch, isolate_logging):
"""Fresh server import with the background scan forced in-process.
Mirrors tests/test_settings_api.py::scan_module the production scan uses
a ``spawn`` ProcessPoolExecutor whose workers an in-process mock can't
reach, so swap in a ThreadPoolExecutor and mock metadata extraction.
"""
import concurrent.futures
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.delenv("DLC_DIR", raising=False)
sys.modules.pop("server", None)
mod = importlib.import_module("server")
monkeypatch.setattr(
mod, "_make_scan_executor",
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
)
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
"""The discovery glob unions over SONG_EXTS, so a `.feedpak` and a
legacy `.sloppak` in the DLC folder are both handed to extraction."""
import unittest.mock as mock
dlc = tmp_path / "dlc"
dlc.mkdir()
# Empty stubs are enough — is_sloppak keys on suffix and extraction is mocked.
(dlc / "new.feedpak").write_bytes(b"")
(dlc / "legacy.sloppak").write_bytes(b"")
(dlc / "ignore.zip").write_bytes(b"") # not a package — must be skipped
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
seen: list[str] = []
def mock_extract(f, dlc_dir):
seen.append(f.name)
return {"title": f.name, "artist": "", "album": ""}
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
scan_server._background_scan()
assert "new.feedpak" in seen
assert "legacy.sloppak" in seen
assert "ignore.zip" not in seen
# ── 3. POST /api/songs/upload gate (endpoint) ────────────────────────────────
@pytest.fixture()
def upload_client(tmp_path, monkeypatch):
"""A TestClient with a temp DLC_DIR and startup side-effects stubbed.
No lifespan is run (the client is not used as a context manager), so the
background scan / plugin load never fire; the upload handler only needs
``_get_dlc_dir`` to resolve, which it does from the DLC_DIR env var.
"""
from fastapi.testclient import TestClient
dlc = tmp_path / "dlc"
dlc.mkdir()
config = tmp_path / "cfg"
config.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("CONFIG_DIR", str(config))
monkeypatch.setenv("SLOPSMITH_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
server.sloppak_mod._source_cache.clear()
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, dlc
finally:
tc.close()
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def _upload(tc, name, data):
return tc.post("/api/songs/upload",
files={"file": (name, data, "application/octet-stream")})
def test_upload_accepts_feedpak(upload_client):
tc, dlc = upload_client
r = _upload(tc, "new_p.feedpak", _feedpak_zip_bytes())
assert r.status_code == 200, r.text
result = r.json()["results"][0]
assert result["status"] == "ok", result
assert (dlc / "new_p.feedpak").exists()
def test_upload_still_accepts_legacy_sloppak(upload_client):
"""Back-compat: a `.sloppak` upload keeps working."""
tc, dlc = upload_client
r = _upload(tc, "legacy.sloppak", _feedpak_zip_bytes())
assert r.status_code == 200, r.text
assert r.json()["results"][0]["status"] == "ok"
assert (dlc / "legacy.sloppak").exists()
def test_upload_rejects_wrong_suffix(upload_client):
"""A valid zip under a non-package suffix is rejected at the ext gate."""
tc, dlc = upload_client
r = _upload(tc, "song.zip", _feedpak_zip_bytes())
assert r.status_code == 200, r.text
result = r.json()["results"][0]
assert result["status"] == "error"
assert "feedpak" in result["error"].lower()
assert not (dlc / "song.zip").exists()
def test_upload_rejects_feedpak_that_is_not_a_zip(upload_client):
"""A `.feedpak` whose bytes aren't a ZIP archive fails the magic check
with the suffix-appropriate message."""
tc, dlc = upload_client
r = _upload(tc, "bogus.feedpak", b"this is not a zip archive")
assert r.status_code == 200, r.text
result = r.json()["results"][0]
assert result["status"] == "error"
assert "feedpak" in result["error"].lower()
assert not (dlc / "bogus.feedpak").exists()
# ── 4. save_settings DLC count (settings handler) ────────────────────────────
@pytest.fixture()
def settings_server(tmp_path, monkeypatch):
"""Fresh server import with an isolated CONFIG_DIR, startup tasks skipped.
save_settings is driven directly it takes the DLC path in its payload, so
no DLC_DIR env or HTTP layer is needed.
"""
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("SLOPSMITH_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
try:
yield server
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def test_settings_dlc_count_includes_both_suffixes(tmp_path, settings_server):
dlc = tmp_path / "dlc"
dlc.mkdir()
(dlc / "a.feedpak").write_bytes(b"")
(dlc / "b.sloppak").write_bytes(b"")
(dlc / "c.FEEDPAK").write_bytes(b"") # case-insensitive (suffix.lower())
(dlc / "notes.txt").write_bytes(b"") # ignored
result = settings_server.save_settings({"dlc_dir": str(dlc)})
assert "error" not in result, result
# save_settings joins its notices into a single ``message`` string.
assert "3 song files" in result.get("message", ""), result