* feat(gallery): save gallery voices as profiles, with validated audio references Work-in-progress lifted from the concurrent gallery session at the owner's request (its uncommitted working tree, preserved verbatim from base 92b1ee5d; safety snapshot remains at rescue/gallery-wip): - gallery voices can be saved as local profiles: audio is copied into the profile store with content-addressed filenames, existing profiles are detected and refreshed only when the source clip changed - backend/core/audio_validation.py: symlink-rejecting, root-contained resolution for persisted profile WAV references, with tests - archetype/community routers and the Voice Gallery UI updated for the save-as-profile handoff (spec: docs/specs/longform/26-gallery-use-handoff.md) - locale updates for the new gallery strings across all 21 files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop a stray local screenshot script that rode in with the tree copy * fix(community): explain the tolerated Content-Length parse failure; drop an unused import CodeQL on #1542: the empty except now says why it is safe (the streamed byte counter enforces the same cap regardless), and the test file loses an unused Path import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gallery): review findings — copy outside the write lock, no stale completions CodeRabbit on #1542, all findings addressed: - the profile-audio copy stages to a .part temp BEFORE BEGIN IMMEDIATE and publishes via atomic os.replace inside it — other backend writers no longer block for the duration of an audio copy; a mid-copy failure leaves no temp droppings and no profile row (both pinned by tests) - VoiceGallery async ops carry per-operation generation tokens: a preview or save-as-profile that resolves after unmount (or after a newer operation) can no longer play audio, redirect into a workspace, or touch state — three fail-before regression tests - VoiceGalleryActions imports the page at test runtime; the e2e locator uses a stable data-testid instead of a translated string; symlink tests skip cleanly where the OS can't create symlinks; the changelog line carries its PR ref Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: static ffmpeg fallback when the chocolatey feed is down Third feed outage to break a PR run (2026-07-20, 2026-07-28, today — three attempts, three 'installed 0/1'). Chocolatey is a distribution channel, not the dependency: after the retry loop exhausts, fetch the static gyan.dev build from its GitHub release mirror and put it on PATH — same binary, no feed in the path. URL verified live (HTTP 200). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
251 lines
8.9 KiB
Python
251 lines
8.9 KiB
Python
"""Gallery-import profile materialization contracts."""
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import sqlite3
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from api.routers import gallery
|
|
from core.db import db_conn, init_db
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
init_db()
|
|
gallery._init_gallery_db()
|
|
app = FastAPI()
|
|
app.include_router(gallery.router)
|
|
return TestClient(app)
|
|
|
|
|
|
def _gallery_voice(
|
|
suffix: str = ".wav", content: bytes = b"RIFF imported voice",
|
|
) -> tuple[str, Path]:
|
|
voice_id = f"g{uuid.uuid4().hex[:7]}"
|
|
path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(content)
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO voice_gallery
|
|
(id, name, character, category, source_type, source_url, audio_path,
|
|
duration, description, tags, created_at)
|
|
VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""",
|
|
(
|
|
voice_id, "Imported narrator", "Video title is not an instruct",
|
|
"https://example.invalid/source", str(path),
|
|
"Source URL/notes are not a spoken transcript", time.time(),
|
|
),
|
|
)
|
|
return voice_id, path
|
|
|
|
|
|
def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client):
|
|
voice_id, _ = _gallery_voice()
|
|
response = client.post(
|
|
f"/gallery/voices/{voice_id}/save-as-profile",
|
|
params={"profile_name": "Reusable import"},
|
|
)
|
|
assert response.status_code == 200
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
|
).fetchone()
|
|
assert row["kind"] == "clone"
|
|
assert row["personality"] == f"gallery:{voice_id}"
|
|
assert row["ref_text"] == ""
|
|
assert row["instruct"] == ""
|
|
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
|
|
|
|
|
def test_to_profile_uses_live_schema_and_clone_metadata(client):
|
|
voice_id, _ = _gallery_voice()
|
|
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
|
assert response.status_code == 200
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
|
).fetchone()
|
|
assert row["kind"] == "clone"
|
|
assert row["personality"] == f"gallery:{voice_id}"
|
|
assert row["ref_text"] == row["instruct"] == ""
|
|
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
|
|
|
|
|
def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch):
|
|
emitted = []
|
|
monkeypatch.setattr(
|
|
gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
|
)
|
|
voice_id, _ = _gallery_voice()
|
|
first = client.post(
|
|
f"/gallery/voices/{voice_id}/save-as-profile",
|
|
params={"profile_name": "One reusable profile"},
|
|
)
|
|
repeated = client.post(
|
|
f"/gallery/voices/{voice_id}/save-as-profile",
|
|
params={"profile_name": "Ignored duplicate name"},
|
|
)
|
|
alternate = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
|
|
|
assert first.status_code == repeated.status_code == alternate.status_code == 200
|
|
assert {
|
|
first.json()["profile_id"],
|
|
repeated.json()["profile_id"],
|
|
alternate.json()["profile_id"],
|
|
} == {first.json()["profile_id"]}
|
|
with db_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE personality=?",
|
|
(f"gallery:{voice_id}",),
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "One reusable profile"
|
|
assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None
|
|
assert emitted[-1] == (
|
|
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
|
|
)
|
|
|
|
|
|
def test_gallery_profile_repairs_a_missing_copy_without_duplication(client):
|
|
voice_id, source = _gallery_voice()
|
|
first = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
|
assert first.status_code == 200
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
|
).fetchone()
|
|
copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"]
|
|
copied.unlink()
|
|
|
|
repaired = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
|
|
|
assert repaired.status_code == 200
|
|
assert repaired.json()["profile_id"] == first.json()["profile_id"]
|
|
assert copied.read_bytes() == source.read_bytes()
|
|
|
|
|
|
def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client):
|
|
voice_id, source = _gallery_voice()
|
|
collision_id = f"c{uuid.uuid4().hex[:7]}"
|
|
personality = f"gallery:{voice_id}"
|
|
collision_name = gallery._gallery_profile_audio_filename(collision_id, source)
|
|
collision_audio = Path(gallery.VOICES_DIR) / collision_name
|
|
collision_audio.parent.mkdir(parents=True, exist_ok=True)
|
|
collision_audio.write_bytes(b"user-owned audio")
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO voice_profiles "
|
|
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
|
"description, kind, vd_states, is_locked, verified_own_voice, created_at) "
|
|
"VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, "
|
|
"'user-owned metadata', 'clone', NULL, 0, 0, ?)",
|
|
(collision_id, collision_name, personality, time.time()),
|
|
)
|
|
|
|
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["profile_id"] != collision_id
|
|
with db_conn() as conn:
|
|
collision = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE id=?", (collision_id,),
|
|
).fetchone()
|
|
created = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
|
).fetchone()
|
|
assert collision["description"] == "user-owned metadata"
|
|
assert collision_audio.read_bytes() == b"user-owned audio"
|
|
assert created["personality"] == personality
|
|
|
|
|
|
def _part_files() -> set[Path]:
|
|
return set(Path(gallery.VOICES_DIR).glob("*.part")) | set(
|
|
Path(gallery.VOICES_DIR).glob(".*.part")
|
|
)
|
|
|
|
|
|
def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch):
|
|
"""The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction.
|
|
|
|
While the copy runs, another backend writer takes (and releases) SQLite's
|
|
write lock. If materialization copied inside its own write transaction,
|
|
this concurrent writer would hit `database is locked` and the test fails.
|
|
"""
|
|
from core.config import DB_PATH
|
|
|
|
voice_id, _ = _gallery_voice()
|
|
real_copy2 = shutil.copy2
|
|
concurrent_writes = []
|
|
|
|
def copy_and_probe(src, dst, **kwargs):
|
|
probe = sqlite3.connect(DB_PATH, timeout=0.5)
|
|
try:
|
|
probe.execute("BEGIN IMMEDIATE")
|
|
probe.execute(
|
|
"UPDATE voice_gallery SET category = category WHERE id = ?",
|
|
(voice_id,),
|
|
)
|
|
probe.commit()
|
|
concurrent_writes.append(True)
|
|
finally:
|
|
probe.close()
|
|
return real_copy2(src, dst, **kwargs)
|
|
|
|
monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe)
|
|
|
|
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
|
|
|
assert response.status_code == 200
|
|
assert concurrent_writes == [True]
|
|
assert _part_files() == set()
|
|
|
|
|
|
def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch):
|
|
"""A copy that dies mid-write must not leave .part files or a DB row."""
|
|
voice_id, _ = _gallery_voice()
|
|
|
|
def exploding_copy(src, dst, **kwargs):
|
|
Path(dst).write_bytes(b"partial bytes")
|
|
raise OSError("disk full mid-copy")
|
|
|
|
monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy)
|
|
|
|
with pytest.raises(OSError, match="disk full mid-copy"):
|
|
client.post(f"/gallery/voices/{voice_id}/to-profile")
|
|
|
|
assert _part_files() == set()
|
|
with db_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM voice_profiles WHERE personality = ?",
|
|
(f"gallery:{voice_id}",),
|
|
).fetchall()
|
|
assert rows == []
|
|
|
|
|
|
def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client):
|
|
voice_id, source = _gallery_voice()
|
|
|
|
response = client.get(
|
|
f"/gallery/voices/{voice_id}/preview", follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert "location" not in response.headers
|
|
assert response.content == source.read_bytes()
|
|
|
|
|
|
def test_gallery_preview_preserves_non_wav_content_type(client):
|
|
voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice")
|
|
|
|
response = client.get(f"/gallery/voices/{voice_id}/preview")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "audio/mpeg"
|