Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion backend/modules/library/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
log = logging.getLogger(__name__)


SCHEMA_VERSION = 4
SCHEMA_VERSION = 5


# Each tuple is (schema_version_after_running, statements list).
Expand Down Expand Up @@ -234,6 +234,16 @@
"CREATE INDEX IF NOT EXISTS idx_entries_play_count ON entries(play_count DESC)",
],
),
(
5,
[
# Stems and MIDI become first-class library items: they can be
# favorited just like parent tracks. Default 0 keeps existing
# rows unflagged.
"ALTER TABLE stems ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE midis ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0",
],
),
]


Expand Down Expand Up @@ -708,6 +718,35 @@ def list_stems(self, entry_id: str) -> list[dict[str, Any]]:
cur.close()
return [dict(r) for r in rows]

def get_stem(self, stem_id: str) -> Optional[dict[str, Any]]:
"""Look one stem row up by its globally-unique id."""
with self._writelock:
cur = self._conn.cursor()
row = cur.execute("SELECT * FROM stems WHERE id = ?", (stem_id,)).fetchone()
cur.close()
return dict(row) if row else None

def set_stem_favorite(self, stem_id: str, favorite: bool) -> bool:
with self._txn() as cur:
cur.execute(
"UPDATE stems SET favorite = ? WHERE id = ?",
(1 if favorite else 0, stem_id),
)
return cur.rowcount > 0

def delete_stem(self, stem_id: str) -> bool:
"""Drop one stem row. Caller is responsible for deleting the file on
disk (the path lives in ``audio_path``)."""
with self._txn() as cur:
cur.execute("DELETE FROM stems WHERE id = ?", (stem_id,))
deleted = cur.rowcount > 0
# Polymorphic edges may reference this stem id (stems-of / midi-of).
cur.execute(
"DELETE FROM relations WHERE from_id = ? OR to_id = ?",
(stem_id, stem_id),
)
return deleted

def add_midi(
self,
*,
Expand Down Expand Up @@ -751,6 +790,34 @@ def list_midis(self, entry_id: str) -> list[dict[str, Any]]:
cur.close()
return [dict(r) for r in rows]

def get_midi(self, midi_id: str) -> Optional[dict[str, Any]]:
"""Look one MIDI row up by its globally-unique id."""
with self._writelock:
cur = self._conn.cursor()
row = cur.execute("SELECT * FROM midis WHERE id = ?", (midi_id,)).fetchone()
cur.close()
return dict(row) if row else None

def set_midi_favorite(self, midi_id: str, favorite: bool) -> bool:
with self._txn() as cur:
cur.execute(
"UPDATE midis SET favorite = ? WHERE id = ?",
(1 if favorite else 0, midi_id),
)
return cur.rowcount > 0

def delete_midi(self, midi_id: str) -> bool:
"""Drop one MIDI row. Caller deletes the .mid file on disk
(path lives in ``midi_path``)."""
with self._txn() as cur:
cur.execute("DELETE FROM midis WHERE id = ?", (midi_id,))
deleted = cur.rowcount > 0
cur.execute(
"DELETE FROM relations WHERE from_id = ? OR to_id = ?",
(midi_id, midi_id),
)
return deleted

def add_notation_artifact(
self,
*,
Expand Down
37 changes: 37 additions & 0 deletions backend/modules/library/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,43 @@ def stream_stem_audio(stem_id: str) -> FileResponse:
raise HTTPException(404, f"stem {stem_id!r} not found")


@router.patch("/stems/{stem_id}")
def update_stem(stem_id: str, patch: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""Mutate a stem row. Currently only ``favorite`` is user-mutable so
stems behave like first-class library items."""
store = get_store()
if store.db is None:
raise HTTPException(503, "library DB not available")
if "favorite" in patch:
ok = store.db.set_stem_favorite(stem_id, bool(patch["favorite"]))
if not ok:
raise HTTPException(404, f"stem {stem_id!r} not found")
row = store.db.get_stem(stem_id)
if row is None:
raise HTTPException(404, f"stem {stem_id!r} not found")
return dict(row)


@router.delete("/stems/{stem_id}")
def delete_stem(stem_id: str) -> dict[str, Any]:
"""Delete one separated stem (its WAV on disk + its DB row), leaving the
parent track and sibling stems untouched."""
store = get_store()
if store.db is None:
raise HTTPException(503, "library DB not available")
row = store.db.get_stem(stem_id)
if row is None:
raise HTTPException(404, f"stem {stem_id!r} not found")
audio_path = Path(row.get("audio_path") or "")
if audio_path.is_file():
try:
audio_path.unlink()
except OSError as e:
log.warning("library: failed to delete stem file %s: %s", audio_path, e)
store.db.delete_stem(stem_id)
return {"deleted": stem_id}


@router.get("/media/{entry_id}")
def stream_media(entry_id: str) -> FileResponse:
"""Stream a video/image library entry. FileResponse honors Range
Expand Down
32 changes: 23 additions & 9 deletions backend/modules/midi/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

from __future__ import annotations

import contextlib
import importlib
import io
import logging
import shutil
import subprocess
Expand Down Expand Up @@ -226,15 +228,27 @@ def _run_basic_pitch(audio_path: Path, output_path: Path) -> dict:
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=str(output_path.parent)) as td:
td_path = Path(td)
predict_and_save(
audio_path_list=[str(audio_path)],
output_directory=str(td_path),
save_midi=True,
sonify_midi=False,
save_model_outputs=False,
save_notes=False,
model_or_model_path=ICASSP_2022_MODEL_PATH,
)
# basic-pitch prints status with emoji (🚨, etc.). On Windows the
# console/log stream is often a legacy code page (cp1252), so the
# library's own print() raises UnicodeEncodeError ('charmap' codec
# can't encode '\U0001f6a8') and kills a conversion that would
# otherwise succeed. Capture its stdout/stderr into a str buffer —
# StringIO holds text, never encodes, so it cannot crash — then log
# the (now harmless) chatter at debug level.
chatter = io.StringIO()
with contextlib.redirect_stdout(chatter), contextlib.redirect_stderr(chatter):
predict_and_save(
audio_path_list=[str(audio_path)],
output_directory=str(td_path),
save_midi=True,
sonify_midi=False,
save_model_outputs=False,
save_notes=False,
model_or_model_path=ICASSP_2022_MODEL_PATH,
)
captured = chatter.getvalue().strip()
if captured:
log.debug("basic_pitch output: %s", captured)
# basic-pitch names: <stem>_basic_pitch.mid
produced = next(td_path.glob("*_basic_pitch.mid"), None)
if produced is None:
Expand Down
40 changes: 39 additions & 1 deletion backend/modules/midi/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

import logging
from pathlib import Path
from typing import Any

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Body, HTTPException
from fastapi.responses import FileResponse

from backend.modules.library.router import get_store as get_library_store
Expand Down Expand Up @@ -89,6 +90,43 @@ def get_midi_file(midi_id: str) -> FileResponse:
raise HTTPException(404, f"midi row {midi_id!r} not found")


@router.patch("/file/{midi_id}")
def update_midi(midi_id: str, patch: dict[str, Any] = Body(...)) -> dict:
"""Mutate a MIDI row. Only ``favorite`` is user-mutable so MIDI rows
behave like first-class library items."""
store = get_library_store()
if store.db is None:
raise HTTPException(503, "library DB not available")
if "favorite" in patch:
ok = store.db.set_midi_favorite(midi_id, bool(patch["favorite"]))
if not ok:
raise HTTPException(404, f"midi row {midi_id!r} not found")
row = store.db.get_midi(midi_id)
if row is None:
raise HTTPException(404, f"midi row {midi_id!r} not found")
return dict(row)


@router.delete("/file/{midi_id}")
def delete_midi_file(midi_id: str) -> dict:
"""Delete one MIDI conversion (its .mid on disk + its DB row), leaving
the parent track untouched."""
store = get_library_store()
if store.db is None:
raise HTTPException(503, "library DB not available")
row = store.db.get_midi(midi_id)
if row is None:
raise HTTPException(404, f"midi row {midi_id!r} not found")
midi_path = Path(row.get("midi_path") or "")
if midi_path.is_file():
try:
midi_path.unlink()
except OSError as e:
log.warning("midi: failed to delete file %s: %s", midi_path, e)
store.db.delete_midi(midi_id)
return {"deleted": midi_id}


@router.get("/{entry_id}")
def list_entry_midis(entry_id: str) -> dict:
store = get_library_store()
Expand Down
Loading
Loading