# SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc import importlib import sys from pathlib import Path from unittest.mock import MagicMock import pytest from solstone.think.importers.shared import ( find_manifest_by_hash, hash_source, write_manifest, ) from solstone.think.importers.sync import load_sync_state, save_sync_state def _write_audio(root: Path, rel_path: str = "clip.mp3", content: bytes = b"audio"): path = root / rel_path path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(content) return path def _patch_audio_tools(monkeypatch, duration: float | None = 60.0): monkeypatch.setattr( "solstone.think.importers.audio.shutil.which", lambda name: f"/usr/bin/{name}", ) monkeypatch.setattr( "solstone.think.importers.audio._get_audio_duration", lambda _path: duration, ) def _write_audio_manifest(journal_root: Path, audio_file: Path, import_id: str): return write_manifest( journal_root, import_id=import_id, source_type="audio", source_hash=hash_source(audio_file), entry_count=1, files_created=[str(audio_file)], days_affected=["20260303"], ) def test_import_one_audio_manifest_is_findable_by_hash(tmp_path, monkeypatch): mod = importlib.import_module("solstone.think.importers.cli") audio_file = tmp_path / "test.mp3" audio_file.write_bytes(b"fake audio") callosum = MagicMock() def fake_prepare_audio_segments(media_path, day_dir, base_dt, import_id, stream): seg_dir = Path(day_dir) / stream / "120000_300" seg_dir.mkdir(parents=True, exist_ok=True) (seg_dir / "imported_audio.mp3").write_bytes(b"sliced audio") return [("120000_300", seg_dir, ["imported_audio.mp3"])] monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr(mod, "CallosumConnection", lambda **kwargs: callosum) monkeypatch.setattr(mod, "get_rev", lambda: "test-rev") monkeypatch.setattr(mod, "_status_emitter", lambda: None) monkeypatch.setattr(mod, "prepare_audio_segments", fake_prepare_audio_segments) monkeypatch.setattr( mod, "update_stream", lambda stream, day, seg, **kwargs: { "prev_day": None, "prev_segment": None, "seq": 1, }, ) monkeypatch.setattr(mod, "write_segment_stream", lambda *args, **kwargs: None) result = mod.import_one( audio_file, timestamp="20260303_120000", source="audio", wait_for_processing=False, ) journal_root = tmp_path source_hash = hash_source(audio_file) manifest = find_manifest_by_hash(journal_root, source_hash) assert result is not None assert manifest is not None assert manifest["source_hash"] == source_hash def test_audio_sync_protocol_conformance(): from solstone.think.importers.audio import AudioFolderBackend from solstone.think.importers.sync import SyncableBackend assert isinstance(AudioFolderBackend(), SyncableBackend) def test_audio_sync_registry_discovery(): from solstone.think.importers.sync import get_syncable_backends backends = get_syncable_backends() assert "audio" in [backend.name for backend in backends] def test_audio_sync_dry_run_catalogs_available_files(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) source_dir = tmp_path / "source" audio_file = _write_audio(source_dir, "nested/clip.mp3", b"available audio") result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) assert result == { "total": 1, "imported": 0, "available": 1, "skipped": 0, "downloaded": 0, "errors": [], } state = load_sync_state(tmp_path, "audio") assert state is not None assert state["backend"] == "audio" assert state["source_path"] == str(source_dir.resolve()) entry = state["files"]["nested/clip.mp3"] assert entry["filename"] == "clip.mp3" assert entry["filesize"] == len(b"available audio") assert entry["hash"] == hash_source(audio_file) assert entry["status"] == "available" def test_audio_sync_manifested_file_reports_imported(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend monkeypatch.setattr( "solstone.think.importers.audio.shutil.which", lambda name: f"/usr/bin/{name}", ) duration = MagicMock(return_value=60.0) monkeypatch.setattr("solstone.think.importers.audio._get_audio_duration", duration) source_dir = tmp_path / "source" audio_file = _write_audio(source_dir, "clip.mp3") _write_audio_manifest(tmp_path, audio_file, "20260303_120000") result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) assert result["imported"] == 1 assert result["available"] == 0 duration.assert_not_called() state = load_sync_state(tmp_path, "audio") assert state is not None assert state["files"]["clip.mp3"]["status"] == "imported" def test_audio_sync_second_sync_after_save_has_zero_available(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) source_dir = tmp_path / "source" audio_file = _write_audio(source_dir, "clip.mp3") def fake_import_one(path, **kwargs): _write_audio_manifest(tmp_path, Path(path), "20260303_120000") return {"segments": ["120000_300"]} monkeypatch.setattr("solstone.think.importers.cli.import_one", fake_import_one) backend = AudioFolderBackend() first = backend.sync(tmp_path, source_path=source_dir, dry_run=False) second = backend.sync(tmp_path, source_path=source_dir, dry_run=True) assert first["downloaded"] == 1 assert audio_file.exists() assert second["imported"] == 1 assert second["available"] == 0 assert second["downloaded"] == 0 def test_audio_sync_skips_too_short_files(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch, duration=29.0) source_dir = tmp_path / "source" _write_audio(source_dir, "short.mp3") result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) assert result["skipped"] == 1 assert result["available"] == 0 state = load_sync_state(tmp_path, "audio") assert state is not None entry = state["files"]["short.mp3"] assert entry["status"] == "skipped" assert entry["skip_reason"] == "too_short" @pytest.mark.parametrize("dry_run", [True, False]) def test_audio_sync_probe_failure_prints_error_in_both_modes( tmp_path, monkeypatch, capsys, dry_run, ): mod = importlib.import_module("solstone.think.importers.cli") monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr( "solstone.think.importers.audio.shutil.which", lambda name: f"/usr/bin/{name}", ) monkeypatch.setattr( "solstone.think.importers.audio._get_audio_duration", lambda path: None if "broken" in path else 29.0, ) monkeypatch.setattr( "solstone.think.importers.cli.import_one", lambda path, **kwargs: {"segments": ["120000_300"]}, ) source_dir = tmp_path / "source" _write_audio(source_dir, "short.mp3") _write_audio(source_dir, "broken.mp3") mod._run_sync("audio", dry_run=dry_run, source_path=source_dir) output = capsys.readouterr().out assert "Errors: 1" in output assert "broken.mp3: could not read audio (probe failed)" in output assert "too short" in output assert "Skipped: 2" not in output assert "Everything is up to date." not in output def test_audio_sync_partitions_available_skipped_and_unreadable(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend monkeypatch.setattr( "solstone.think.importers.audio.shutil.which", lambda name: f"/usr/bin/{name}", ) def duration_for(path): name = Path(path).name if name == "broken.mp3": return None if name == "short.mp3": return 29.0 return 60.0 monkeypatch.setattr( "solstone.think.importers.audio._get_audio_duration", duration_for ) source_dir = tmp_path / "source" _write_audio(source_dir, "long.mp3") _write_audio(source_dir, "short.mp3") _write_audio(source_dir, "broken.mp3") result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) assert result["available"] == 1 assert result["skipped"] == 1 assert len(result["errors"]) == 1 state = load_sync_state(tmp_path, "audio") assert state is not None assert state["files"]["broken.mp3"]["status"] == "unreadable" def test_audio_sync_probe_failure_recovery_is_not_sticky(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch, duration=None) source_dir = tmp_path / "source" _write_audio(source_dir, "clip.mp3") backend = AudioFolderBackend() backend.sync(tmp_path, source_path=source_dir, dry_run=True) first_state = load_sync_state(tmp_path, "audio") assert first_state is not None assert first_state["files"]["clip.mp3"]["status"] == "unreadable" monkeypatch.setattr( "solstone.think.importers.audio._get_audio_duration", lambda _path: 60.0, ) backend.sync(tmp_path, source_path=source_dir, dry_run=True) state = load_sync_state(tmp_path, "audio") assert state is not None entry = state["files"]["clip.mp3"] assert entry["status"] == "available" assert "last_error" not in entry assert "skip_reason" not in entry def test_run_sync_skipped_reason_visible_without_verbose(tmp_path, monkeypatch, capsys): mod = importlib.import_module("solstone.think.importers.cli") monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _patch_audio_tools(monkeypatch, duration=29.0) source_dir = tmp_path / "source" _write_audio(source_dir, "short.mp3") mod._run_sync("audio", dry_run=True, source_path=source_dir) output = capsys.readouterr().out assert "too short" in output skipped_lines = [ line.rstrip() for line in output.splitlines() if "Skipped:" in line ] assert " Skipped: 1" not in skipped_lines def test_run_sync_verbose_prints_file_dump(tmp_path, monkeypatch, capsys): mod = importlib.import_module("solstone.think.importers.cli") monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr( "solstone.think.importers.audio.shutil.which", lambda name: f"/usr/bin/{name}", ) monkeypatch.setattr( "solstone.think.importers.audio._get_audio_duration", lambda path: 29.0 if Path(path).name == "short.mp3" else 60.0, ) source_dir = tmp_path / "source" _write_audio(source_dir, "long.mp3") _write_audio(source_dir, "short.mp3") mod._run_sync("audio", dry_run=True, verbose=True, source_path=source_dir) verbose_output = capsys.readouterr().out assert "Files:" in verbose_output assert "available" in verbose_output assert "long.mp3" in verbose_output assert "skipped" in verbose_output assert "short.mp3" in verbose_output mod._run_sync("audio", dry_run=True, verbose=False, source_path=source_dir) plain_output = capsys.readouterr().out assert "Files:" not in plain_output def test_importer_main_sync_verbose_wiring(tmp_path, monkeypatch, capsys): mod = importlib.import_module("solstone.think.importers.cli") monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr(mod, "require_solstone", lambda: None) monkeypatch.setattr( sys, "argv", ["sol", "--sync", "audio", "--path", str(tmp_path / "source"), "-v"], ) _patch_audio_tools(monkeypatch) _write_audio(tmp_path / "source", "clip.mp3") mod.main() output = capsys.readouterr().out assert "Files:" in output assert "clip.mp3" in output def test_run_sync_verbose_handles_obsidian_shaped_state( tmp_path, monkeypatch, capsys, ): mod = importlib.import_module("solstone.think.importers.cli") monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) save_sync_state( tmp_path, "audio", { "backend": "audio", "files": { "clip.md": { "filename": "clip.md", "status": "available", }, "done.md": { "filename": "done.md", "status": "imported", }, }, }, ) class FakeAudioBackend: name = "audio" def sync(self, journal_root, *, dry_run=True, source_path=None): return { "total": 2, "imported": 1, "available": 1, "skipped": 0, "downloaded": 0, "errors": [], } monkeypatch.setattr( "solstone.think.importers.sync.get_syncable_backends", lambda: [FakeAudioBackend()], ) mod._run_sync("audio", dry_run=True, verbose=True, source_path=tmp_path / "source") output = capsys.readouterr().out assert "Files:" in output assert "clip.md" in output assert "done.md" in output def test_audio_sync_auto_passthrough_from_main(tmp_path, monkeypatch, capsys): mod = importlib.import_module("solstone.think.importers.cli") monkeypatch.setattr(mod, "require_solstone", lambda: None) _patch_audio_tools(monkeypatch) import_one = MagicMock(return_value={"segments": ["120000_300"]}) monkeypatch.setattr("solstone.think.importers.cli.import_one", import_one) guidance_journal = tmp_path / "guidance-journal" guidance_source = tmp_path / "guidance-source" _write_audio(guidance_source, "clip.mp3") monkeypatch.setenv("SOLSTONE_JOURNAL", str(guidance_journal)) monkeypatch.setattr( sys, "argv", [ "sol", "--sync", "audio", "--path", str(guidance_source), "--save", "--auto", "guidance text", ], ) mod.main() assert import_one.call_args.kwargs["auto"] == "guidance text" default_journal = tmp_path / "default-journal" default_source = tmp_path / "default-source" _write_audio(default_source, "clip.mp3", b"default audio") monkeypatch.setenv("SOLSTONE_JOURNAL", str(default_journal)) monkeypatch.setattr( sys, "argv", ["sol", "--sync", "audio", "--path", str(default_source), "--save"], ) import_one.reset_mock() capsys.readouterr() mod.main() assert import_one.call_args.kwargs["auto"] is True dry_run_journal = tmp_path / "dry-run-journal" dry_run_source = tmp_path / "dry-run-source" _write_audio(dry_run_source, "clip.mp3", b"dry-run audio") monkeypatch.setenv("SOLSTONE_JOURNAL", str(dry_run_journal)) monkeypatch.setattr( sys, "argv", [ "sol", "--sync", "audio", "--path", str(dry_run_source), "--auto", "catalog guidance", ], ) import_one.reset_mock() capsys.readouterr() mod.main() import_one.assert_not_called() def test_audio_sync_save_imports_new_files_and_checkpoints(tmp_path, monkeypatch): import solstone.think.importers.audio as audio_mod from solstone.think.importers.audio import AudioFolderBackend from solstone.think.importers.sync import save_sync_state as real_save_sync_state _patch_audio_tools(monkeypatch) source_dir = tmp_path / "source" _write_audio(source_dir, "one.mp3") _write_audio(source_dir, "two.mp3") monkeypatch.setattr( "solstone.think.importers.cli.import_one", lambda path, **kwargs: {"segments": ["120000_300"]}, ) save_calls = [] def spy_save_sync_state(journal_root, backend, state): save_calls.append(backend) real_save_sync_state(journal_root, backend, state) monkeypatch.setattr(audio_mod, "save_sync_state", spy_save_sync_state) result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=False) assert result["downloaded"] == 2 assert result["imported"] == 2 assert len(save_calls) == 3 @pytest.mark.parametrize( ("mode", "expected_status", "expected_downloaded", "expects_error"), [ ("success", "imported", 1, False), ("skipped", "available", 0, True), ("raised", "available", 0, True), ("none", "available", 0, True), ], ) def test_audio_sync_import_one_return_shapes( tmp_path, monkeypatch, mode, expected_status, expected_downloaded, expects_error, ): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) source_dir = tmp_path / "source" _write_audio(source_dir, "clip.mp3") def fake_import_one(path, **kwargs): if mode == "success": return {"segments": ["120000_300"]} if mode == "skipped": return {"skipped": True, "reason": "already_imported"} if mode == "raised": raise RuntimeError("boom") return None monkeypatch.setattr("solstone.think.importers.cli.import_one", fake_import_one) result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=False) assert result["downloaded"] == expected_downloaded assert bool(result["errors"]) is expects_error state = load_sync_state(tmp_path, "audio") assert state is not None entry = state["files"]["clip.mp3"] assert entry["status"] == expected_status if expects_error: assert entry["last_error"] else: assert "last_error" not in entry def test_audio_sync_removed_file_marks_removed(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) source_dir = tmp_path / "source" _write_audio(source_dir, "keep.mp3") removed = _write_audio(source_dir, "removed.mp3") backend = AudioFolderBackend() backend.sync(tmp_path, source_path=source_dir, dry_run=True) removed.unlink() result = backend.sync(tmp_path, source_path=source_dir, dry_run=True) assert result["total"] == 2 state = load_sync_state(tmp_path, "audio") assert state is not None assert state["files"]["keep.mp3"]["status"] == "available" assert state["files"]["removed.mp3"]["status"] == "removed" def test_audio_sync_removed_skipped_file_marks_removed(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend monkeypatch.setattr( "solstone.think.importers.audio.shutil.which", lambda name: f"/usr/bin/{name}", ) monkeypatch.setattr( "solstone.think.importers.audio._get_audio_duration", lambda path: 29.0 if Path(path).name == "short.mp3" else 60.0, ) source_dir = tmp_path / "source" other_source_dir = tmp_path / "other-source" _write_audio(source_dir, "short.mp3") _write_audio(other_source_dir, "long.mp3") backend = AudioFolderBackend() first = backend.sync(tmp_path, source_path=source_dir, dry_run=True) assert first["skipped"] == 1 second = backend.sync(tmp_path, source_path=other_source_dir, dry_run=True) assert second["skipped"] == 0 assert second["available"] == 1 state = load_sync_state(tmp_path, "audio") assert state is not None assert state["files"]["short.mp3"]["status"] == "removed" assert state["files"]["long.mp3"]["status"] == "available" def test_audio_sync_force_save_does_not_reimport_manifested_file(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) source_dir = tmp_path / "source" audio_file = _write_audio(source_dir, "clip.mp3") _write_audio_manifest(tmp_path, audio_file, "20260303_120000") import_one = MagicMock(return_value={"segments": ["120000_300"]}) monkeypatch.setattr("solstone.think.importers.cli.import_one", import_one) result = AudioFolderBackend().sync( tmp_path, source_path=source_dir, dry_run=False, force=True, ) assert result["imported"] == 1 assert result["available"] == 0 assert result["downloaded"] == 0 import_one.assert_not_called() def test_audio_sync_missing_path_raises_value_error(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) with pytest.raises(ValueError, match="--path"): AudioFolderBackend().sync(tmp_path) def test_audio_sync_non_directory_path_raises_value_error(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) source_path = tmp_path / "not-a-dir.mp3" source_path.write_bytes(b"audio") with pytest.raises(ValueError, match="not a directory"): AudioFolderBackend().sync(tmp_path, source_path=source_path) def test_audio_sync_no_audio_files_raises_value_error(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) source_dir = tmp_path / "source" source_dir.mkdir() (source_dir / "notes.txt").write_text("not audio", encoding="utf-8") with pytest.raises(ValueError, match="no audio"): AudioFolderBackend().sync(tmp_path, source_path=source_dir) @pytest.mark.parametrize("missing_tool", ["ffmpeg", "ffprobe"]) def test_audio_sync_missing_tool_fails_fast(tmp_path, monkeypatch, missing_tool): from solstone.think.importers.audio import AudioFolderBackend source_dir = tmp_path / "source" source_dir.mkdir() def fake_which(name): if name == missing_tool: return None return f"/usr/bin/{name}" monkeypatch.setattr("solstone.think.importers.audio.shutil.which", fake_which) with pytest.raises(ValueError, match=missing_tool): AudioFolderBackend().sync(tmp_path, source_path=source_dir) def test_audio_sync_excludes_journal_imports_subtree(tmp_path, monkeypatch): from solstone.think.importers.audio import AudioFolderBackend _patch_audio_tools(monkeypatch) _write_audio(tmp_path, "root.mp3") _write_audio(tmp_path, "imports/20260303_120000/imported_audio.mp3") result = AudioFolderBackend().sync(tmp_path, source_path=tmp_path, dry_run=True) assert result["total"] == 1 state = load_sync_state(tmp_path, "audio") assert state is not None assert set(state["files"]) == {"root.mp3"} def test_run_sync_dry_run_hint_includes_path_for_audio(tmp_path, monkeypatch, capsys): mod = importlib.import_module("solstone.think.importers.cli") source_dir = tmp_path / "audio source" source_dir.mkdir() monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) class FakeAudioBackend: name = "audio" def sync(self, journal_root, *, dry_run=True, source_path=None): save_sync_state( journal_root, "audio", { "backend": "audio", "source_path": str(source_path), "files": { "clip.mp3": { "filename": "clip.mp3", "filesize": 4, "status": "available", } }, }, ) return { "total": 1, "imported": 0, "available": 1, "skipped": 0, "downloaded": 0, "errors": [], } monkeypatch.setattr( "solstone.think.importers.sync.get_syncable_backends", lambda: [FakeAudioBackend()], ) mod._run_sync("audio", dry_run=True, source_path=source_dir) output = capsys.readouterr().out assert f"journal importer --sync audio --save --path {source_dir}" in output def test_run_sync_dry_run_hint_omits_path_for_pathless_backend( tmp_path, monkeypatch, capsys, ): mod = importlib.import_module("solstone.think.importers.cli") source_dir = tmp_path / "audio" source_dir.mkdir() monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) class FakeAudioBackend: name = "audio" def sync(self, journal_root, *, dry_run=True): save_sync_state( journal_root, "audio", { "backend": "audio", "files": { "clip.mp3": { "filename": "clip.mp3", "filesize": 4, "status": "available", } }, }, ) return { "total": 1, "imported": 0, "available": 1, "skipped": 0, "downloaded": 0, "errors": [], } monkeypatch.setattr( "solstone.think.importers.sync.get_syncable_backends", lambda: [FakeAudioBackend()], ) mod._run_sync("audio", dry_run=True, source_path=source_dir) output = capsys.readouterr().out assert "journal importer --sync audio --save" in output assert "--path" not in output def test_run_sync_save_errors_suppress_up_to_date_message( tmp_path, monkeypatch, capsys, ): mod = importlib.import_module("solstone.think.importers.cli") monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) class FakeAudioBackend: name = "audio" def sync(self, journal_root, *, dry_run=True): return { "total": 1, "imported": 0, "available": 0, "skipped": 0, "downloaded": 0, "errors": ["clip.mp3: boom"], } monkeypatch.setattr( "solstone.think.importers.sync.get_syncable_backends", lambda: [FakeAudioBackend()], ) mod._run_sync("audio", dry_run=False) output = capsys.readouterr().out assert "Errors: 1" in output assert "Everything is up to date." not in output