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:
@@ -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"]
|
||||
Reference in New Issue
Block a user