diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19b3d8d0..67cd8ad4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
"fret" no longer reads as an arbitrary number. Tests:
`tests/anchor_authoring.test.js` (full-set promotion on click + on add-new,
undo-restores-fallback, idempotent-when-authored).
+- **Create a song from audio alone — "New…" no longer forces a Guitar Pro / EOF
+ file.** The create modal labelled its Guitar Pro and EOF XML inputs *optional*
+ but hard-disabled the Create button unless one was picked, and its
+ blank-chart options (initial arrangement + drum tab) were never wired up — so
+ an audio-only feedpak could only be made through a second, separate "New
+ Sloppak" dialog. The modal now leads with an explicit **Blank / Guitar Pro /
+ EOF XML** mode picker (Blank is the default): Blank shows the initial-
+ arrangement toggle (Lead / Rhythm / Bass) + a drum-tab option and enables
+ Create on audio + title + artist, routing to the existing `create_sloppak`
+ backend and opening the new feedpak straight in the editor; the GP and EOF
+ modes keep their import flows. The "New" chooser's entries now open this one
+ unified modal (in Blank / GP mode) instead of a parallel dialog, and the
+ toolbar's **Build Song** button is renamed **Build feedpak**. Tests:
+ `tests/create_gate.test.js` (per-mode Create-button enable logic). Keys /
+ Drums-as-arrangement and extended tunings still need `create_sloppak` backend
+ work (tracked separately); the now-unreferenced standalone New-Sloppak dialog
+ can be removed in a follow-up.
### Changed
- **The audio lane now draws a real waveform.** It previously showed a
diff --git a/routes.py b/routes.py
index 15aa9d93..8ad2e022 100644
--- a/routes.py
+++ b/routes.py
@@ -1315,6 +1315,58 @@ def _guide_tones_wire(tones):
if isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 11]
+def _extended_manifest_meta(src) -> dict:
+ """Normalize the spec-complete optional manifest fields (album_artist, track,
+ disc, genres, isrc, mbid, language, authors) from a create/import request
+ into the shapes `_write_sloppak_pak` expects — so a Guitar Pro / EOF import
+ persists the same metadata the blank-create path does (these fields are shown
+ for every create flow but were previously written only for blank projects).
+ Lenient: a malformed field is skipped, not an error (secondary to the import).
+ """
+ if not isinstance(src, dict):
+ return {}
+ out: dict = {}
+
+ def _s(name):
+ v = src.get(name)
+ return v.strip() if isinstance(v, str) else ""
+
+ for _k in ("album_artist", "language"):
+ if _s(_k):
+ out[_k] = _s(_k)
+ if _s("mbid"):
+ out["mbid"] = _s("mbid").lower()
+ if _s("isrc"):
+ out["isrc"] = re.sub(r"[\s-]", "", _s("isrc")).upper()
+ for _k in ("track", "disc"):
+ v = src.get(_k)
+ if isinstance(v, bool):
+ continue
+ if isinstance(v, int):
+ out[_k] = v
+ elif isinstance(v, str) and v.strip().lstrip("-").isdigit():
+ out[_k] = int(v.strip())
+
+ def _list(name):
+ v = src.get(name)
+ if isinstance(v, str):
+ v = re.split(r"[,\n]", v)
+ items, seen = [], set()
+ if isinstance(v, list):
+ for it in v:
+ s = str(it).strip() if it is not None else ""
+ if s and s not in seen:
+ seen.add(s)
+ items.append(s)
+ return items
+
+ for _k in ("genres", "authors"):
+ vals = _list(_k)
+ if vals:
+ out[_k] = vals
+ return out
+
+
def _safe_float(v, default=0.0):
"""Best-effort float coercion; returns `default` for bad input."""
if v is None:
@@ -2761,6 +2813,54 @@ def _name_meta(full: Path) -> tuple:
files.sort(key=lambda x: x["filename"])
return files
+ @app.post("/api/plugins/editor/browse")
+ async def browse_dlc(data: dict):
+ """List ONE directory level inside the DLC/song-library folder so the
+ Load dialog can act as a file browser rooted at the library. `path` is a
+ DLC-relative POSIX subpath ("" = the library root). Returns the absolute
+ root (for display), the current relative path + its parent, the
+ subfolders, and the loadable feedpak/sloppak files at this level."""
+ dlc_dir = get_dlc_dir()
+ if not dlc_dir or not dlc_dir.exists():
+ return JSONResponse({"error": "DLC folder not configured"}, 400)
+ root = dlc_dir.resolve()
+ rel = str((data or {}).get("path") or "").strip().replace("\\", "/").strip("/")
+ target = (root / rel).resolve() if rel else root
+ # Containment — never list outside the library.
+ try:
+ target.relative_to(root)
+ except ValueError:
+ return JSONResponse({"error": "forbidden"}, 403)
+ if not target.is_dir():
+ target, rel = root, ""
+ _FORMATS = {".feedpak": "sloppak", ".sloppak": "sloppak"}
+ dirs: list = []
+ files: list = []
+ try:
+ entries = sorted(target.iterdir(), key=lambda p: p.name.lower())
+ except OSError:
+ entries = []
+ for entry in entries:
+ ext = entry.suffix.lower()
+ try:
+ erel = entry.relative_to(root).as_posix()
+ except ValueError:
+ continue
+ if entry.is_dir():
+ if ext in (".feedpak", ".sloppak"):
+ # Authoring-form package dir — a loadable "file", not a folder.
+ files.append({"filename": erel, "name": entry.name, "format": "sloppak"})
+ else:
+ dirs.append({"name": entry.name, "path": erel})
+ elif entry.is_file() and ext in _FORMATS:
+ files.append({"filename": erel, "name": entry.name, "format": _FORMATS[ext]})
+ parent = None
+ if rel:
+ p = Path(rel).parent.as_posix()
+ parent = "" if p == "." else p
+ return {"root": str(root), "cwd": rel, "parent": parent,
+ "dirs": dirs, "files": files}
+
# ── Load a custom song for editing ──────────────────────────────────────────
@app.post("/api/plugins/editor/load")
@@ -3698,8 +3798,10 @@ async def create_sloppak(
if audio_url is not None and not isinstance(audio_url, str):
return JSONResponse({"error": "audio_url must be a string"}, 400)
audio_url = (audio_url or "").strip()
- if audio is None and not audio_url:
- return JSONResponse({"error": "audio file or audio_url required"}, 400)
+ # Draft-now, audio-later: audio is OPTIONAL. With neither an upload nor a
+ # pre-uploaded audio_url we still create a work-in-progress pack (empty
+ # `stems: []`); the author supplies real audio later via Replace Audio.
+ has_audio = audio is not None or bool(audio_url)
# Optional album art, passed as a storage path returned by
# /upload-art. Validate containment under STORAGE_DIR before
@@ -3738,72 +3840,99 @@ def _str_field(name: str, default: str = "") -> str | None:
return JSONResponse({"error": "artist must be a string"}, 400)
if not title:
return JSONResponse({"error": "title required"}, 400)
- if not artist:
- return JSONResponse({"error": "artist required"}, 400)
-
- arr_name = _str_field("initial_arrangement", "Lead")
- if arr_name is None:
+ # Artist is OPTIONAL for a draft — only the title is required to create a
+ # work-in-progress pack. `artist` may be "" here (written through as-is).
+
+ # Optional "Authored by" credit(s) -> manifest `authors:` (feedpak is an
+ # open format; credit whoever makes the file, don't gate on it). Accept a
+ # single string (the modal's one text field; users comma/newline-separate
+ # names) OR a list. Normalize to a de-duped list of trimmed strings.
+ authors_in = meta_in.get("authors")
+ if isinstance(authors_in, str):
+ authors_in = re.split(r"[,\n]", authors_in)
+ authors: list[str] = []
+ if isinstance(authors_in, list):
+ _seen_auth: set[str] = set()
+ for _a in authors_in:
+ if isinstance(_a, str) and _a.strip() and _a.strip() not in _seen_auth:
+ _seen_auth.add(_a.strip())
+ authors.append(_a.strip())
+ elif authors_in is not None:
return JSONResponse(
- {"error": "initial_arrangement must be a string"}, 400,
+ {"error": "authors must be a string or a list of strings"}, 400,
)
- if not arr_name:
- arr_name = "Lead"
- if arr_name not in ("Lead", "Rhythm", "Bass"):
- return JSONResponse(
- {"error": "initial_arrangement must be Lead, Rhythm, or Bass"},
- 400,
- )
-
- # Strict length per chosen arrangement. Bass = 4 strings, the
- # rest = 6. Reject anything else (1, 7, 20, …) so a malformed
- # client can't accidentally request an extended-range or
- # truncated arrangement through this entry point. (Extended-
- # range support exists via the save-as-sloppak path, not here.)
- expected_strings = 4 if arr_name == "Bass" else 6
- tuning_in = meta_in.get("tuning")
def _is_int(v) -> bool:
- # bool is a subclass of int in Python; treat True/False as
- # invalid here so a `True → 1` doesn't slip past.
+ # bool is a subclass of int in Python; treat True/False as invalid
+ # so a `True → 1` doesn't slip past int fields (track/disc).
return isinstance(v, int) and not isinstance(v, bool)
- if tuning_in is None:
- tuning = [0] * expected_strings
- elif isinstance(tuning_in, list) and len(tuning_in) == expected_strings:
- if not all(_is_int(t) for t in tuning_in):
+ # Roster of arrangements to seed ("What are you arranging?"). The modal
+ # sends `arrangements`: the list of canonical role names the user dragged
+ # in. Back-compat: a single `initial_arrangement` string is still
+ # accepted. Roles map to editor modes by NAME — Lead/Rhythm/Bass →
+ # fretted, Keys → piano-roll (type:piano), Drums → drum_tab. Vocals is
+ # NOT an arrangement (feedpak models it as side-files); it seeds an empty
+ # lyrics track and needs at least one instrument alongside it, since the
+ # spec requires a non-empty arrangements list. String counts are NOT
+ # chosen here — fretted roles get a default standard tuning (Bass 4,
+ # guitar 6) and the editor extends the range (up to 9-string guitar /
+ # 6-string bass) after creation.
+ _FRETTED = ("Lead", "Rhythm", "Bass")
+ _INSTRUMENTS = _FRETTED + ("Keys", "Drums")
+ _ALL_ROLES = _INSTRUMENTS + ("Vocals",)
+ _ROLE_ALIASES = {
+ "Lead Guitar": "Lead", "Rhythm Guitar": "Rhythm",
+ "Bass Guitar": "Bass", "Piano": "Keys", "Keyboard": "Keys",
+ "Vocal": "Vocals", "Voice": "Vocals",
+ }
+
+ roster_in = meta_in.get("arrangements")
+ if roster_in is None:
+ # Legacy shape: `initial_arrangement` (single role) + `init_drum_tab`
+ # (bool, default True). Translate both into the roster so an older
+ # client still gets its requested drum tab — in the new model a
+ # "Drums" role IS the drum tab (see init_drums below).
+ single = _str_field("initial_arrangement", "Lead") or "Lead"
+ roster_in = [single]
+ legacy_drums = meta_in.get("init_drum_tab", True)
+ if not isinstance(legacy_drums, bool):
+ return JSONResponse({"error": "init_drum_tab must be a boolean"}, 400)
+ if legacy_drums:
+ roster_in.append("Drums")
+ if not isinstance(roster_in, list) or not roster_in:
+ return JSONResponse(
+ {"error": "arrangements must be a non-empty list of roles"}, 400,
+ )
+ roster: list[str] = []
+ for _r in roster_in:
+ if not isinstance(_r, str):
return JSONResponse(
- {"error": "tuning entries must be integers (no floats or booleans)"},
+ {"error": "each arrangement role must be a string"}, 400,
+ )
+ _r = _ROLE_ALIASES.get(_r.strip(), _r.strip())
+ if _r not in _ALL_ROLES:
+ return JSONResponse(
+ {"error": (
+ f"unknown arrangement role '{_r}' — expected one of "
+ f"{', '.join(_ALL_ROLES)}"
+ )},
400,
)
- tuning = list(tuning_in)
- else:
+ if _r not in roster:
+ roster.append(_r)
+ want_vocals = "Vocals" in roster
+ instrument_roster = [r for r in roster if r in _INSTRUMENTS]
+ if not instrument_roster:
return JSONResponse(
{"error": (
- f"tuning must be a list of {expected_strings} ints "
- f"for an initial '{arr_name}' arrangement"
+ "select at least one instrument to chart "
+ "(Lead, Rhythm, Bass, Keys, or Drums)"
)},
400,
)
-
- capo_raw = meta_in.get("capo", 0)
- if not _is_int(capo_raw):
- return JSONResponse(
- {"error": "capo must be an integer"}, 400,
- )
- if capo_raw < 0:
- return JSONResponse(
- {"error": "capo must be non-negative"}, 400,
- )
- capo = capo_raw
-
- # Strict bool check — client JSON-derived; the string "false"
- # would otherwise become truthy and silently enable drum_tab.
- init_drums_raw = meta_in.get("init_drum_tab", True)
- if not isinstance(init_drums_raw, bool):
- return JSONResponse(
- {"error": "init_drum_tab must be a boolean"}, 400,
- )
- init_drums = init_drums_raw
+ # A Drums arrangement IS its drum tab — seed one iff Drums is selected.
+ init_drums = "Drums" in instrument_roster
dlc_dir = get_dlc_dir()
if not dlc_dir:
@@ -3825,12 +3954,14 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
safe_a = _truncate_utf8(
re.sub(r'[<>:"/\\|?*]', "_", artist), _MAX_FILENAME_PART_BYTES,
).rstrip(". ")
- if not safe_t or not safe_a:
+ if not safe_t:
return JSONResponse(
- {"error": "title and artist must contain non-blank characters"},
+ {"error": "title must contain non-blank characters"},
400,
)
- new_filename = f"{safe_t}_{safe_a}.feedpak"
+ # Artist may be blank on a draft (or sanitise to empty) — fall back to a
+ # title-only filename rather than an ugly trailing-underscore name.
+ new_filename = f"{safe_t}_{safe_a}.feedpak" if safe_a else f"{safe_t}.feedpak"
output_path = (dlc_dir / new_filename).resolve()
# Containment check — the sanitiser above strips path separators,
# but defend against a `.. .. ..` -style title that somehow
@@ -3879,6 +4010,9 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
".mp3", ".wav", ".flac", ".m4a", ".ogg", ".opus",
".aac", ".aiff", ".wma",
}
+ # Stays None when no audio is supplied (draft-now): the encode block
+ # below is guarded on it and `ogg_path` stays None.
+ in_ext = None
if audio is not None:
_raw_ext = Path(audio.filename or "audio").suffix.lower()
in_ext = _raw_ext if _raw_ext in _KNOWN_AUDIO_EXTS else ".bin"
@@ -3893,7 +4027,7 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
if not chunk:
break
dst.write(chunk)
- else:
+ elif audio_url:
# Pre-uploaded audio: resolve the storage URL to a local
# file (validates containment) and copy it into the temp
# dir so the re-encode path below is identical to the
@@ -3908,61 +4042,78 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
in_path = upload_dir / f"upload{in_ext}"
shutil.copy2(resolved_audio, in_path)
- ogg_path = upload_dir / "audio.ogg"
- if in_ext == ".ogg":
- # Rename instead of copy — both paths live in the same
- # upload_dir (deleted at the end), so duplicating the
- # bytes just doubles peak temp disk for nothing.
- in_path.rename(ogg_path)
- # Same min-size sanity check the re-encode branch
- # enforces — a 0-byte / truncated .ogg would otherwise
- # produce a sloppak that fails on first open.
- if (not ogg_path.exists()
- or ogg_path.stat().st_size < 100):
- return JSONResponse(
- {"error": "uploaded .ogg is empty or too small to play"},
- 400,
- )
- else:
- from lib.audio import _ffmpeg_cmd, _ffmpeg_wav_to_ogg
- ffmpeg = _ffmpeg_cmd()
- if not ffmpeg:
- return JSONResponse(
- {"error": "ffmpeg not available — can't re-encode audio"},
- 500,
- )
- try:
- r = await asyncio.get_event_loop().run_in_executor(
- None, _ffmpeg_wav_to_ogg, ffmpeg, in_path, ogg_path,
- )
- except Exception:
- # str(e) on subprocess / OSError commonly embeds the
- # ffmpeg cmd line and absolute paths — log it
- # server-side and return a generic message.
- import logging as _log
- _log.getLogger("slopsmith.plugin.editor").exception(
- "create_sloppak: ffmpeg re-encode raised",
- )
- return JSONResponse(
- {"error": "audio re-encode failed — see server logs"},
- 400,
- )
- if (r.returncode != 0 or not ogg_path.exists()
- or ogg_path.stat().st_size < 100):
- return JSONResponse(
- {"error": "ffmpeg failed to decode the uploaded audio"},
- 400,
- )
+ # None → no audio supplied: skip encode, leave ogg_path None so the
+ # write emits an empty `stems: []` draft.
+ ogg_path = None
+ if in_ext is not None:
+ ogg_path = upload_dir / "audio.ogg"
+ if in_ext == ".ogg":
+ # Rename instead of copy — both paths live in the same
+ # upload_dir (deleted at the end), so duplicating the
+ # bytes just doubles peak temp disk for nothing.
+ in_path.rename(ogg_path)
+ # Same min-size sanity check the re-encode branch
+ # enforces — a 0-byte / truncated .ogg would otherwise
+ # produce a sloppak that fails on first open.
+ if (not ogg_path.exists()
+ or ogg_path.stat().st_size < 100):
+ return JSONResponse(
+ {"error": "uploaded .ogg is empty or too small to play"},
+ 400,
+ )
+ else:
+ from lib.audio import _ffmpeg_cmd, _ffmpeg_wav_to_ogg
+ ffmpeg = _ffmpeg_cmd()
+ if not ffmpeg:
+ return JSONResponse(
+ {"error": "ffmpeg not available — can't re-encode audio"},
+ 500,
+ )
+ try:
+ r = await asyncio.get_event_loop().run_in_executor(
+ None, _ffmpeg_wav_to_ogg, ffmpeg, in_path, ogg_path,
+ )
+ except Exception:
+ # str(e) on subprocess / OSError commonly embeds the
+ # ffmpeg cmd line and absolute paths — log it
+ # server-side and return a generic message.
+ import logging as _log
+ _log.getLogger("slopsmith.plugin.editor").exception(
+ "create_sloppak: ffmpeg re-encode raised",
+ )
+ return JSONResponse(
+ {"error": "audio re-encode failed — see server logs"},
+ 400,
+ )
+ if (r.returncode != 0 or not ogg_path.exists()
+ or ogg_path.stat().st_size < 100):
+ return JSONResponse(
+ {"error": "ffmpeg failed to decode the uploaded audio"},
+ 400,
+ )
- # Build the minimal in-memory shapes _write_sloppak_pak expects.
- arrangements_data = [{
- "name": arr_name,
- "tuning": tuning,
- "capo": capo,
- "notes": [],
- "chords": [],
- "chord_templates": [],
- }]
+ # Build one in-memory arrangement per instrument role in the roster.
+ # Fretted roles carry a default standard tuning (Bass 4 strings,
+ # guitar 6 — the editor extends the count after creation); Keys
+ # carries the spec `type: piano`; Drums opens the drum lane by name.
+ # Each opens in the right editor mode by NAME (KEYS_PATTERN / ^drums).
+ def _default_tuning(role: str) -> list:
+ if role in _FRETTED:
+ return [0] * (4 if role == "Bass" else 6)
+ return []
+ arrangements_data = []
+ for _role in instrument_roster:
+ _arr = {
+ "name": _role,
+ "tuning": _default_tuning(_role),
+ "capo": 0,
+ "notes": [],
+ "chords": [],
+ "chord_templates": [],
+ }
+ if _role == "Keys":
+ _arr["type"] = "piano"
+ arrangements_data.append(_arr)
# Seed a minimal one-measure 4/4 @ 120 BPM grid (downbeat +
# three sub-beats + next downbeat = 5 beats). The Tempo Map
# editor bails when beats.length < 2 ("No beat grid…"), so
@@ -4001,12 +4152,78 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
return JSONResponse(
{"error": "year must be a number or string"}, 400,
)
+ # ── Spec-complete manifest metadata (feedpak §5.1) ──
+ # Every field is OPTIONAL and written through only when present.
+ def _opt_str(name):
+ v = meta_in.get(name)
+ if v is None:
+ return ""
+ if not isinstance(v, str):
+ raise ValueError(f"{name} must be a string")
+ return v.strip()
+
+ def _opt_int(name):
+ v = meta_in.get(name)
+ if v in (None, ""):
+ return None
+ if isinstance(v, str) and v.strip().lstrip("-").isdigit():
+ return int(v.strip())
+ if _is_int(v):
+ return v
+ raise ValueError(f"{name} must be an integer")
+
+ try:
+ album_artist = _opt_str("album_artist")
+ language = _opt_str("language")
+ mbid = _opt_str("mbid").lower()
+ # ISRC: the bare 12-char code — strip the display hyphens/spaces
+ # and upper-case (feedpak §5.1). Not hard-rejected on length so a
+ # partial draft entry is tolerated.
+ isrc = re.sub(r"[\s-]", "", _opt_str("isrc")).upper()
+ track_no = _opt_int("track")
+ disc_no = _opt_int("disc")
+ except ValueError as _e:
+ return JSONResponse({"error": str(_e)}, 400)
+
+ # genres: accept a list OR a comma/newline string; normalise to a
+ # de-duped list of trimmed strings (most-specific first).
+ genres_in = meta_in.get("genres")
+ if isinstance(genres_in, str):
+ genres_in = re.split(r"[,\n]", genres_in)
+ genres: list[str] = []
+ if isinstance(genres_in, list):
+ _seen_g: set[str] = set()
+ for _g in genres_in:
+ if isinstance(_g, str) and _g.strip() and _g.strip() not in _seen_g:
+ _seen_g.add(_g.strip())
+ genres.append(_g.strip())
+ elif genres_in is not None:
+ return JSONResponse(
+ {"error": "genres must be a string or a list of strings"}, 400,
+ )
+
meta_out = {
"title": title,
"artist": artist,
"album": album_raw,
"year": year_raw,
}
+ if authors:
+ meta_out["authors"] = authors
+ if album_artist:
+ meta_out["album_artist"] = album_artist
+ if track_no is not None:
+ meta_out["track"] = track_no
+ if disc_no is not None:
+ meta_out["disc"] = disc_no
+ if genres:
+ meta_out["genres"] = genres
+ if mbid:
+ meta_out["mbid"] = mbid
+ if isrc:
+ meta_out["isrc"] = isrc
+ if language:
+ meta_out["language"] = language
drum_tab: dict | None = None
if init_drums:
@@ -4018,6 +4235,11 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
"hits": [],
}
+ # Vocals in the roster seeds an EMPTY lyrics track (feedpak models
+ # vocals as side-files, not an arrangement). An empty list is a valid
+ # lyrics.json — the author fills syllables in later.
+ lyrics_seed: list | None = [] if want_vocals else None
+
# Probe the actual audio duration so the sloppak's
# manifest.duration reflects the song, not the 2-second
# placeholder beat grid. Best-effort: if the probe fails
@@ -4029,7 +4251,7 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
ffprobe = _bundled_or_path("ffprobe") or shutil.which("ffprobe")
except Exception:
ffprobe = shutil.which("ffprobe")
- if ffprobe:
+ if ffprobe and ogg_path is not None:
try:
pr = await asyncio.get_event_loop().run_in_executor(
None,
@@ -4049,8 +4271,15 @@ def _truncate_utf8(s: str, max_bytes: int) -> str:
audio_duration = None
def _do_write():
+ # Auto-generate the hover-to-listen preview from the master audio
+ # (no manual upload) — best-effort; absent for audio-less drafts.
+ preview_clip = ""
+ if ogg_path is not None:
+ _pv = _make_preview_clip(Path(ogg_path), upload_dir)
+ if _pv:
+ preview_clip = str(_pv)
return _write_sloppak_pak(
- audio_file=str(ogg_path),
+ audio_file=(str(ogg_path) if ogg_path is not None else ""),
art_path=art_path,
arrangements_data=arrangements_data,
beats=beats,
@@ -4058,6 +4287,8 @@ def _do_write():
meta=meta_out,
output_path=output_path,
drum_tab=drum_tab,
+ lyrics=lyrics_seed,
+ preview_path=preview_clip,
fail_if_exists=True,
duration_override=audio_duration,
)
@@ -4115,6 +4346,130 @@ async def upload_art(file: UploadFile = File(...)):
dest.write_bytes(content)
return {"art_path": str(dest)}
+ # ── Cover Art Archive — album-art picker from a MusicBrainz release ─────
+ # Cover art lives in the Cover Art Archive (CAA), keyed by RELEASE MBID —
+ # which the create modal's MusicBrainz "Match" already carries (a candidate's
+ # `release_id`). Fetched server-side (dodges browser CORS + gives us the
+ # bytes to bake into the pack) and cached under STORAGE_DIR.
+ _CAA_ID_RE = re.compile(r"^[0-9a-fA-F-]{1,64}$")
+ _CAA_MAX_BYTES = 10 * 1024 * 1024
+ _CAA_UA = "feedBack-editor/1.0 ( https://github.com/got-feedback/feedBack )"
+
+ _CAA_SECONDARY_SKIP = {"live", "compilation", "remix", "dj-mix",
+ "mixtape/street", "demo", "interview", "audiobook",
+ "spokenword"}
+
+ def _caa_fetch_front(cover_id: str, size: int = 500, kind: str = "release"):
+ """Front cover (size px) for a MusicBrainz `kind` ('release' or
+ 'release-group') from coverartarchive.org, or None when there's no art /
+ on any error. `cover_id` is regex-validated by callers before the URL."""
+ import urllib.request
+ url = f"https://coverartarchive.org/{kind}/{cover_id}/front-{size}"
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": _CAA_UA})
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ if getattr(resp, "status", 200) != 200:
+ return None
+ data = resp.read(_CAA_MAX_BYTES + 1)
+ except Exception:
+ return None
+ if not data or len(data) > _CAA_MAX_BYTES or len(data) < 100:
+ return None
+ return data
+
+ async def _caa_cached(cover_id: str, kind: str = "release"):
+ """Cached cover file (fetch + cache on first use), or None when no art."""
+ tag = "rg_" if kind == "release-group" else ""
+ dest = STORAGE_DIR / f"caa_{tag}{cover_id}.jpg"
+ if dest.exists() and dest.stat().st_size > 100:
+ return dest
+ data = await asyncio.get_event_loop().run_in_executor(
+ None, _caa_fetch_front, cover_id, 500, kind)
+ if data is None:
+ return None
+ dest.write_bytes(data)
+ return dest
+
+ @app.get("/api/plugins/editor/caa-cover/{cover_id}")
+ async def caa_cover(cover_id: str, group: int = 0):
+ """Serve the CAA front cover for a release (or release-group when
+ ?group=1), cached. 404 when there's no art — the picker hides the tile.
+ Same-origin so it loads under the app's CSP without an external host."""
+ if not _CAA_ID_RE.match(cover_id or ""):
+ return JSONResponse({"error": "invalid id"}, 400)
+ dest = await _caa_cached(cover_id, "release-group" if group else "release")
+ if dest is None:
+ return JSONResponse({"error": "no cover art"}, 404)
+ return FileResponse(dest, media_type="image/jpeg")
+
+ @app.post("/api/plugins/editor/use-caa-cover")
+ async def use_caa_cover(data: dict):
+ """Pick a CAA cover as the pack's album art: fetch/cache it and return an
+ art_path under STORAGE_DIR that create_sloppak bakes in like an upload."""
+ cover_id = str((data or {}).get("release_id") or "")
+ kind = "release-group" if (data or {}).get("group") else "release"
+ if not _CAA_ID_RE.match(cover_id):
+ return JSONResponse({"error": "invalid id"}, 400)
+ dest = await _caa_cached(cover_id, kind)
+ if dest is None:
+ return JSONResponse({"error": "no cover art"}, 404)
+ return {"art_path": str(dest)}
+
+ # Album-centric cover search. MusicBrainz RELEASE-GROUPS carry reliable Album
+ # vs Live/Compilation typing (unlike recording releases), so searching them by
+ # artist + album/title and preferring the studio album surfaces the CANONICAL
+ # cover first — the fix for "art search shows random comp covers". Works
+ # art-first for title-tracks; for other songs, fill the Album (or run Match).
+ def _mb_release_group_covers(artist: str, query: str) -> list:
+ """Release-group search → [{id, title, year, studio}] studio-albums first."""
+ import urllib.request
+ import urllib.parse
+
+ def _phrase(s):
+ return s.replace("\\", "\\\\").replace('"', '\\"')
+
+ parts = []
+ if query:
+ parts.append('releasegroup:"%s"' % _phrase(query))
+ if artist:
+ parts.append('artist:"%s"' % _phrase(artist))
+ if not parts:
+ return []
+ url = ("https://musicbrainz.org/ws/2/release-group?"
+ + urllib.parse.urlencode(
+ {"query": " AND ".join(parts), "fmt": "json", "limit": 15}))
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": _CAA_UA})
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ body = json.loads(resp.read().decode("utf-8", "replace"))
+ except Exception:
+ return []
+ out = []
+ for rg in (body.get("release-groups") or []):
+ if not isinstance(rg, dict) or not rg.get("id"):
+ continue
+ secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
+ studio = (str(rg.get("primary-type", "")).lower() == "album"
+ and not (secs & _CAA_SECONDARY_SKIP))
+ out.append({
+ "id": str(rg["id"]),
+ "title": str(rg.get("title", "") or ""),
+ "year": str(rg.get("first-release-date", "") or "")[:4],
+ "studio": studio,
+ })
+ out.sort(key=lambda g: (0 if g["studio"] else 1, g["year"] or "9999"))
+ return out
+
+ @app.get("/api/plugins/editor/cover-search")
+ async def cover_search(artist: str = "", query: str = ""):
+ """Album-centric cover candidates (release-groups) for the art picker.
+ `query` is the ALBUM (best) or the song title. Studio album first."""
+ if not (artist.strip() or query.strip()):
+ return {"covers": []}
+ covers = await asyncio.get_event_loop().run_in_executor(
+ None, _mb_release_group_covers, artist.strip(), query.strip())
+ return {"covers": covers}
+
# ── Upload hover-preview clip ──────────────────────────────────────
@app.post("/api/plugins/editor/upload-preview")
async def upload_preview(file: UploadFile = File(...)):
@@ -4154,11 +4509,17 @@ async def upload_audio(file: UploadFile = File(...)):
dest = STORAGE_DIR / f"editor_audio_{audio_id}{out_path.suffix.lower()}"
shutil.copy2(out_path, dest)
shutil.rmtree(out_path.parent, ignore_errors=True)
- return {"audio_url": f"{STORAGE_URL}/{dest.name}"}
+ _dur = await asyncio.get_event_loop().run_in_executor(
+ None, _probe_audio_duration, dest)
+ return {"audio_url": f"{STORAGE_URL}/{dest.name}", "duration": _dur}
dest = STORAGE_DIR / f"editor_audio_{audio_id}{ext}"
dest.write_bytes(content)
- return {"audio_url": f"{STORAGE_URL}/editor_audio_{audio_id}{ext}"}
+ # Duration lets the create modal's MusicBrainz "Match" rank candidates by
+ # how close their length is to this master audio (the studio-vs-live tell).
+ _dur = await asyncio.get_event_loop().run_in_executor(
+ None, _probe_audio_duration, dest)
+ return {"audio_url": f"{STORAGE_URL}/editor_audio_{audio_id}{ext}", "duration": _dur}
# ── Download audio from YouTube ──────────────────────────────────
@@ -4285,6 +4646,11 @@ async def replace_audio(data: dict):
manifest = dict(session["sloppak_state"].get("manifest") or {})
rel = f"stems/{dest.name}"
manifest["stems"] = [{"id": "full", "file": rel}]
+ # Regenerate the auto preview from the new master audio so a draft
+ # that had no audio at create (and thus no preview) gets one now.
+ _pv = _make_preview_clip(dest, source_dir)
+ if _pv and _pv.exists():
+ manifest["preview"] = _pv.name
(source_dir / "manifest.yaml").write_text(
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
encoding="utf-8",
@@ -4360,9 +4726,30 @@ def _detect_embedded():
None, _detect_embedded
)
+ # Song metadata for non-destructive autofill of the create modal's
+ # Title / Artist / Album fields (convert-gp reads those same fields, so
+ # this just saves retyping). Best-effort — a parse failure returns blanks
+ # and the modal simply falls back to the filename-derived title.
+ def _gp_song_meta():
+ try:
+ import guitarpro
+ song = guitarpro.parse(gp_path)
+ return {
+ "title": (getattr(song, "title", "") or "").strip(),
+ "artist": (getattr(song, "artist", "") or "").strip(),
+ "album": (getattr(song, "album", "") or "").strip(),
+ }
+ except Exception:
+ return {"title": "", "artist": "", "album": ""}
+
+ song_meta = await asyncio.get_event_loop().run_in_executor(
+ None, _gp_song_meta
+ )
+
return {"gp_path": gp_path, "tracks": tracks,
"has_embedded_audio": has_audio,
- "sync_point_count": sync_count}
+ "sync_point_count": sync_count,
+ "song": song_meta}
@@ -4887,6 +5274,9 @@ def _convert():
"metadata": {
"title": title, "artist": artist,
"album": album, "year": year,
+ # Spec-complete metadata typed in the create modal is merged into
+ # the session so /build persists it too (not just blank projects).
+ **_extended_manifest_meta(data),
},
"last_touched": time.time(),
}
@@ -4970,6 +5360,7 @@ async def import_xml_project(
artist: str = Form(""),
album: str = Form(""),
year: str = Form(""),
+ extended_meta: str = Form(""),
):
"""Import EOF (Editor on Fire) arrangement XML into a create session.
@@ -4985,6 +5376,13 @@ async def import_xml_project(
"""
from lib.song import load_song as _load_arrangement_dir
+ # Spec-complete metadata arrives as one JSON form field so we don't need
+ # eight more Form() params; a malformed blob is tolerated (just ignored).
+ try:
+ _ext_meta = json.loads(extended_meta) if extended_meta.strip() else {}
+ except (ValueError, TypeError):
+ _ext_meta = {}
+
tmp = tempfile.mkdtemp(prefix="slopsmith_xmlproj_")
try:
xml_count = 0
@@ -5082,6 +5480,10 @@ def _load():
"metadata": {
"title": song.title, "artist": song.artist,
"album": song.album, "year": song.year,
+ # Spec-complete metadata from the create modal (a JSON blob
+ # form field) is merged so /build persists it, matching the
+ # blank-create and GP-import paths.
+ **_extended_manifest_meta(_ext_meta),
},
"last_touched": time.time(),
}
@@ -5897,10 +6299,77 @@ def _build_sloppak():
# ── Helpers ──────────────────────────────────────────────────────────
+ def _probe_audio_duration(path) -> "float | None":
+ """Best-effort audio duration in seconds via ffprobe (None on failure)."""
+ try:
+ from lib.audio import _bundled_or_path
+ ffprobe = _bundled_or_path("ffprobe") or shutil.which("ffprobe")
+ except Exception:
+ ffprobe = shutil.which("ffprobe")
+ if not ffprobe:
+ return None
+ try:
+ pr = subprocess.run(
+ [ffprobe, "-v", "error", "-show_entries", "format=duration",
+ "-of", "csv=p=0", str(path)],
+ capture_output=True, timeout=15,
+ )
+ if pr.returncode == 0:
+ return round(float(pr.stdout.strip()), 3)
+ except Exception:
+ pass
+ return None
+
+ def _make_preview_clip(src: "Path", out_dir: "Path",
+ clip_seconds: float = 28.0) -> "Path | None":
+ """Auto-generate a short hover-to-listen preview from the master audio —
+ an OGG snippet starting ~20% into the song (past most intros). Best-effort:
+ returns None if ffmpeg is unavailable or the source is missing/too short.
+ Replaces the old manual "Preview Clip" upload."""
+ try:
+ from lib.audio import _ffmpeg_cmd
+ ffmpeg = _ffmpeg_cmd()
+ except Exception:
+ ffmpeg = None
+ if not ffmpeg or not src or not Path(src).exists():
+ return None
+ start, dur = 0.0, None
+ try:
+ from lib.audio import _bundled_or_path
+ ffprobe = _bundled_or_path("ffprobe") or shutil.which("ffprobe")
+ except Exception:
+ ffprobe = shutil.which("ffprobe")
+ if ffprobe:
+ try:
+ pr = subprocess.run(
+ [ffprobe, "-v", "error", "-show_entries", "format=duration",
+ "-of", "csv=p=0", str(src)],
+ capture_output=True, timeout=15,
+ )
+ if pr.returncode == 0:
+ dur = float(pr.stdout.strip())
+ except Exception:
+ dur = None
+ if dur and dur > clip_seconds + 2:
+ start = max(0.0, min(dur * 0.2, dur - clip_seconds))
+ out = out_dir / "preview.ogg"
+ try:
+ subprocess.run(
+ [ffmpeg, "-y", "-ss", str(round(start, 3)), "-t", str(clip_seconds),
+ "-i", str(src), "-vn", "-c:a", "libvorbis", "-q:a", "4", str(out)],
+ capture_output=True, timeout=60,
+ )
+ except Exception:
+ return None
+ if out.exists() and out.stat().st_size > 100:
+ return out
+ return None
+
def _write_sloppak_pak(*, audio_file: str, art_path: str,
arrangements_data: list, beats: list, sections: list,
meta: dict, output_path: Path,
drum_tab: dict | None = None,
+ lyrics: list | None = None,
preview_path: str = "",
fail_if_exists: bool = False,
duration_override: float | None = None) -> str:
@@ -5918,7 +6387,11 @@ def _write_sloppak_pak(*, audio_file: str, art_path: str,
path writes drum_tab via its own staging logic, not through
this helper.
"""
- if not audio_file or not Path(audio_file).exists():
+ # Draft-now packs carry no audio (audio_file == ""): emit an empty
+ # `stems: []` the author fills in later via Replace Audio. A NON-empty
+ # audio_file that doesn't resolve is still a real error.
+ _has_audio = bool(audio_file)
+ if _has_audio and not Path(audio_file).exists():
raise RuntimeError("No audio file available for sloppak write")
# Sloppak supports a packed-zip form (foo.sloppak file) and an
# authoring directory form (foo.sloppak/ tree). Replacing a
@@ -5952,35 +6425,38 @@ def _write_sloppak_pak(*, audio_file: str, art_path: str,
# it `audio.ogg` here meant those consumers couldn't find the mix.
# Transcode to OGG when the source isn't already OGG so playback
# works regardless of the uploaded audio format.
- audio_ext = Path(audio_file).suffix.lower()
- if audio_ext == ".ogg":
- stem_filename = "full.ogg"
- shutil.copy2(audio_file, stems_dir / stem_filename)
- else:
- # Try to transcode to the conventional `full.ogg` so on-create
- # stem separation can find the source mix.
- try:
- from lib.audio import _ffmpeg_cmd, _ffmpeg_wav_to_ogg
- _ff = _ffmpeg_cmd()
- except Exception:
- _ff = None
- _ogg_dest = stems_dir / "full.ogg"
- _ok = False
- if _ff:
- _r = _ffmpeg_wav_to_ogg(_ff, Path(audio_file), _ogg_dest)
- _ok = (_r.returncode == 0 and _ogg_dest.exists()
- and _ogg_dest.stat().st_size >= 100)
- if _ok:
+ # No audio (draft): stem_filename stays None -> manifest `stems: []`.
+ stem_filename = None
+ if _has_audio:
+ audio_ext = Path(audio_file).suffix.lower()
+ if audio_ext == ".ogg":
stem_filename = "full.ogg"
- else:
- # ffmpeg unavailable or transcode failed — keep the source
- # under its REAL extension (and point the manifest at it)
- # rather than writing mislabeled bytes to a `.ogg` name,
- # which would break decode/playback. Stem-split (which keys
- # on full.ogg) is best-effort and simply won't run here.
- _ogg_dest.unlink(missing_ok=True)
- stem_filename = f"full{audio_ext}"
shutil.copy2(audio_file, stems_dir / stem_filename)
+ else:
+ # Try to transcode to the conventional `full.ogg` so on-create
+ # stem separation can find the source mix.
+ try:
+ from lib.audio import _ffmpeg_cmd, _ffmpeg_wav_to_ogg
+ _ff = _ffmpeg_cmd()
+ except Exception:
+ _ff = None
+ _ogg_dest = stems_dir / "full.ogg"
+ _ok = False
+ if _ff:
+ _r = _ffmpeg_wav_to_ogg(_ff, Path(audio_file), _ogg_dest)
+ _ok = (_r.returncode == 0 and _ogg_dest.exists()
+ and _ogg_dest.stat().st_size >= 100)
+ if _ok:
+ stem_filename = "full.ogg"
+ else:
+ # ffmpeg unavailable or transcode failed — keep the source
+ # under its REAL extension (and point the manifest at it)
+ # rather than writing mislabeled bytes to a `.ogg` name,
+ # which would break decode/playback. Stem-split (which keys
+ # on full.ogg) is best-effort and simply won't run here.
+ _ogg_dest.unlink(missing_ok=True)
+ stem_filename = f"full{audio_ext}"
+ shutil.copy2(audio_file, stems_dir / stem_filename)
used_ids: set[str] = set()
manifest_arrangements = []
@@ -6083,13 +6559,41 @@ def _write_sloppak_pak(*, audio_file: str, art_path: str,
# `id: "full"` matches the convention the editor's load
# path and replace-audio path already use; sloppak
# readers prefer that id when picking the default stem.
- "stems": [
- {"id": "full", "file": f"stems/{stem_filename}"},
- ],
+ # Draft packs (no audio yet) carry an empty stems list — the
+ # loader tolerates it (audio_url resolves to null) and Replace
+ # Audio fills it in later.
+ "stems": (
+ [{"id": "full", "file": f"stems/{stem_filename}"}]
+ if stem_filename else []
+ ),
"arrangements": manifest_arrangements,
}
if year:
manifest["year"] = year
+ # Optional author credits (feedpak `authors:` array). Written only
+ # when non-empty so packs without credits stay byte-identical.
+ _authors = meta.get("authors")
+ if isinstance(_authors, list):
+ _authors = [str(a).strip() for a in _authors if str(a).strip()]
+ if _authors:
+ manifest["authors"] = _authors
+
+ # Spec-complete optional metadata (feedpak §5.1) — written only when
+ # present so packs without them stay minimal. String scalars,
+ # int scalars (track/disc), and the genres list.
+ for _mk in ("album_artist", "mbid", "isrc", "language"):
+ _mv = meta.get(_mk)
+ if isinstance(_mv, str) and _mv.strip():
+ manifest[_mk] = _mv.strip()
+ for _mk in ("track", "disc"):
+ _mv = meta.get(_mk)
+ if isinstance(_mv, int) and not isinstance(_mv, bool):
+ manifest[_mk] = _mv
+ _genres = meta.get("genres")
+ if isinstance(_genres, list):
+ _genres = [str(g).strip() for g in _genres if str(g).strip()]
+ if _genres:
+ manifest["genres"] = _genres
if art_path and Path(art_path).exists():
cover_ext = Path(art_path).suffix.lower() or ".jpg"
@@ -6126,6 +6630,17 @@ def _write_sloppak_pak(*, audio_file: str, art_path: str,
)
manifest["drum_tab"] = "drum_tab.json"
+ # Vocals seed: an empty (or authored) lyrics track. feedpak §7.1
+ # lyrics.json is a flat array of syllables — an empty array is a
+ # valid, empty track the author fills in later.
+ if isinstance(lyrics, list):
+ (staging / "lyrics.json").write_text(
+ json.dumps(lyrics, separators=(",", ":")),
+ encoding="utf-8",
+ )
+ manifest["lyrics"] = "lyrics.json"
+ manifest["lyrics_source"] = "authored"
+
_write_song_timeline_sidecar(staging, manifest, beats, sections)
(staging / "manifest.yaml").write_text(
diff --git a/screen.html b/screen.html
index d9d63f4a..7569dde9 100644
--- a/screen.html
+++ b/screen.html
@@ -23,7 +23,7 @@
-
+
@@ -180,17 +180,21 @@
-
+
- Load custom song
+ Load a feedpak
+
+
Song library folder
+
—
+
-
-
Loading...
+
Loading…
@@ -213,50 +217,68 @@
-
+
- Create New custom song
+ Create New Arrangement
-
+
+
+
+
+ Pick what you're arranging and give it a title — everything else is optional.
+ Import a chart or audio to start from, or build from scratch and add content later.
+
-
-
-
-
+
+
+
+
+
+
+
+
+
What are you arranging?
+
Click to add. Drag the chips below to reorder.
+
+
Your arrangements (drag to reorder):
+
+
-
-
-
-
-
Select the EOF arrangement file(s), then set the audio, art, preview and details below and click Import.
+
+
+
Content Import — optional
+
Add a master audio track and/or a chart to start from — you can add both. feedBack accepts audio (MP3/WAV/FLAC/OGG/M4A), Guitar Pro (.gp3–.gp8), MIDI (.mid), and EOF/RS arrangement XML. PowerTab & MusicXML are coming.
+
+
+
+
+
+
+ or paste a YouTube URL
+
+
+
-
+
-
-
-
-
-
-
Default tuning is E standard (Bass: 4 strings). Adjust later from the editor toolbar.
-
-
-
-
@@ -296,73 +318,129 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
Album Art — optional
+
+
No art yet
+
+
+
+
+
+
Upload a file, or find a cover from MusicBrainz.
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
Song details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- Chart author(s)
+
+
Credited in the feedpak (spec authors) — distinct from the performing artist.
-
-
-
-
+
+
+
+
+
Match metadata
+
Look this song up on MusicBrainz, or identify the exact recording from your audio.
+
+
+
+
+
+
+
-
-
-
-
-
Baked into the .sloppak when you Build. Plays on hover in the library only if the Song Preview plugin is installed.
-
+
+
+
-
-
+
diff --git a/screen.js b/screen.js
index 0a5d0bc2..d4c54398 100644
--- a/screen.js
+++ b/screen.js
@@ -4936,8 +4936,12 @@ function updateArrangementSelector() {
async function showLoadModal() {
const modal = document.getElementById('editor-load-modal');
modal.classList.remove('hidden');
- document.getElementById('editor-load-search').value = '';
+ const search = document.getElementById('editor-load-search');
+ if (search) search.value = '';
+ // Preload the flat list ONCE for recursive search (best-effort). The default
+ // view is the folder browser below; typing in the search box searches across
+ // every folder using this list.
if (!S.songsList) {
try {
S.songsList = await fetch('/api/plugins/editor/songs').then(r => r.json());
@@ -4945,14 +4949,81 @@ async function showLoadModal() {
S.songsList = [];
}
}
- // Don't dump the whole library on open — it can be thousands of rows.
- // Show a prompt and populate results as the user types (see filterSongs).
- // If the library is empty / the fetch failed, fall through to the
- // existing "No custom song files found" empty state instead of telling the user
- // to search for something that isn't there.
- if (S.songsList && S.songsList.length) renderSongPrompt();
- else renderSongList(S.songsList || []);
- document.getElementById('editor-load-search').focus();
+ // Open as a file browser rooted at the DLC / song-library folder.
+ await _editorBrowse('');
+ if (search) search.focus();
+}
+
+// Fetch + render one directory level of the library. `path` is a DLC-relative
+// POSIX subpath ("" = the library root).
+async function _editorBrowse(path) {
+ S.loadCwd = path || '';
+ const list = document.getElementById('editor-load-list');
+ let data;
+ try {
+ data = await fetch('/api/plugins/editor/browse', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: S.loadCwd }),
+ }).then(r => r.json());
+ } catch {
+ data = { error: 'Could not read the library folder' };
+ }
+ if (!data || data.error) {
+ if (list) list.innerHTML = '