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 @@