Initial toni-sync CLI: sync local playlist folders to Kreativ-Tonies

- Config commands (config add/list/remove) to map local audio folders
  to Kreativ-Tonies via TonieCloud household/tonie ids
- tonies list command to look up household/tonie ids
- sync command: diff-based upload of new tracks, pruning of removed
  chapters, and reordering to match local file order (via tonie-api)
- Credentials resolved via env vars/CLI flags/prompt, never stored
- Unit tests for config persistence and sync planning/apply logic

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-13 22:41:00 +02:00
co-authored by Copilot
commit 4860edf886
10 changed files with 727 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from tonie_api.models import Chapter, CreativeTonie
from toni_sync.config import ToniMapping
from toni_sync.sync import apply_plan, build_plan, list_local_tracks, title_from_filename
def make_tonie(chapters: list[Chapter]) -> CreativeTonie:
return CreativeTonie(
id="t1",
householdId="h1",
name="Test Tonie",
imageUrl="",
secondsRemaining=100,
secondsPresent=0,
chaptersRemaining=10,
chaptersPresent=len(chapters),
transcoding=False,
lastUpdate=None,
chapters=chapters,
)
def make_chapter(title: str, chapter_id: str = "c1") -> Chapter:
return Chapter(id=chapter_id, title=title, file="file1", seconds=10, transcoding=False)
@pytest.fixture
def folder(tmp_path: Path) -> Path:
(tmp_path / "01 - First.mp3").write_bytes(b"data")
(tmp_path / "02 - Second.mp3").write_bytes(b"data")
(tmp_path / "notes.txt").write_text("ignore me")
return tmp_path
def test_list_local_tracks_filters_by_extension(folder: Path):
tracks = list_local_tracks(folder)
assert [t.name for t in tracks] == ["01 - First.mp3", "02 - Second.mp3"]
def test_title_from_filename_strips_extension(folder: Path):
track = folder / "01 - First.mp3"
assert title_from_filename(track) == "01 - First"
def test_list_local_tracks_missing_folder_raises(tmp_path: Path):
with pytest.raises(FileNotFoundError):
list_local_tracks(tmp_path / "missing")
def test_build_plan_detects_upload_and_no_removal(folder: Path):
tonie = make_tonie([make_chapter("01 - First")])
api = MagicMock()
api.get_all_creative_tonies.return_value = [tonie]
mapping = ToniMapping(name="m", household_id="h1", tonie_id="t1", folder=str(folder))
plan = build_plan(api, mapping)
assert [p.name for p in plan.to_upload] == ["02 - Second.mp3"]
assert plan.to_remove == []
assert plan.needs_changes is True
def test_build_plan_prunes_removed_chapters(folder: Path):
tonie = make_tonie([make_chapter("01 - First"), make_chapter("stale", "c2")])
api = MagicMock()
api.get_all_creative_tonies.return_value = [tonie]
mapping = ToniMapping(name="m", household_id="h1", tonie_id="t1", folder=str(folder))
plan = build_plan(api, mapping)
assert [c.title for c in plan.to_remove] == ["stale"]
def test_build_plan_no_prune_keeps_stale_chapters(folder: Path):
tonie = make_tonie([make_chapter("01 - First"), make_chapter("stale", "c2")])
api = MagicMock()
api.get_all_creative_tonies.return_value = [tonie]
mapping = ToniMapping(name="m", household_id="h1", tonie_id="t1", folder=str(folder), prune=False)
plan = build_plan(api, mapping)
assert plan.to_remove == []
assert plan.final_order_titles == ["01 - First", "02 - Second", "stale"]
def test_apply_plan_uploads_and_reorders(folder: Path):
initial = make_tonie([make_chapter("01 - First", "c1")])
api = MagicMock()
# get_all_creative_tonies is called twice: once in build_plan, once in apply_plan (refetch)
after_upload = make_tonie(
[make_chapter("01 - First", "c1"), make_chapter("02 - Second", "c2")]
)
api.get_all_creative_tonies.side_effect = [[initial], [after_upload]]
mapping = ToniMapping(name="m", household_id="h1", tonie_id="t1", folder=str(folder))
plan = build_plan(api, mapping)
apply_plan(api, mapping, plan)
api.upload_file_to_tonie.assert_called_once()
args, _ = api.upload_file_to_tonie.call_args
assert args[1].name == "02 - Second.mp3"
assert args[2] == "02 - Second"
# order already matches after upload -> no sort call needed in this case
api.sort_chapter_of_tonie.assert_not_called()
def test_apply_plan_reorders_when_order_differs(folder: Path):
# Existing chapters are in reverse order relative to local files.
initial = make_tonie(
[make_chapter("02 - Second", "c2"), make_chapter("01 - First", "c1")]
)
api = MagicMock()
api.get_all_creative_tonies.side_effect = [[initial], [initial]]
mapping = ToniMapping(name="m", household_id="h1", tonie_id="t1", folder=str(folder))
plan = build_plan(api, mapping)
apply_plan(api, mapping, plan)
api.upload_file_to_tonie.assert_not_called()
api.sort_chapter_of_tonie.assert_called_once()
_, ordered = api.sort_chapter_of_tonie.call_args[0]
assert [c.title for c in ordered] == ["01 - First", "02 - Second"]