commit 4860edf886f6d393458f755da8f31004079e2ac1 Author: arnef Date: Thu Aug 13 22:41:00 2026 +0200 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> diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e9f706 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.egg-info/ +.venv/ +venv/ +build/ +dist/ +.pytest_cache/ +.coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfe101e --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# toni-sync + +Ein CLI-Tool zur Verwaltung von Kreativ-Tonies: Es synchronisiert lokale +Audio-Ordner (z. B. exportierte Deezer-Playlists) automatisch zu den +passenden Kreativ-Tonies über die inoffizielle TonieCloud-API +([`tonie-api`](https://pypi.org/project/tonie-api/)). + +> **Hinweis:** toni-sync lädt selbst keine Musik von Deezer herunter. Das +> Herunterladen/Umgehen von DRM-geschützten Streams verstößt gegen die +> Nutzungsbedingungen von Deezer und ggf. gegen Urheberrecht. Lege deine +> bereits legal exportierten Audiodateien einfach in die konfigurierten +> lokalen Ordner - toni-sync kümmert sich nur um den Abgleich mit der +> TonieCloud. + +## Installation + +```bash +pip install -e . +``` + +## Anmeldedaten + +toni-sync benötigt deine TonieCloud-Zugangsdaten (dieselben wie in der +Tonies-App). Sie werden **nicht** gespeichert, sondern bei jedem Aufruf +über Umgebungsvariablen oder interaktiven Prompt abgefragt: + +```bash +export TONI_SYNC_USERNAME="you@example.com" +export TONI_SYNC_PASSWORD="********" +``` + +Alternativ: `--username`/`--password` Optionen bei `tonies list` und `sync`. + +## Nutzung + +### 1. Household- und Tonie-IDs herausfinden + +```bash +toni-sync tonies list +``` + +Beispielausgabe: + +``` +Household: Familie Müller [id=abcd-1234] + - Peppa Wutz [id=ef01-5678] (12 chapters, 3600s) + - Gute-Nacht-Geschichten [id=9876-4321] (0 chapters, 0s) +``` + +### 2. Playlist <-> Toni Mapping konfigurieren + +```bash +toni-sync config add \ + --name peppa-wutz \ + --household-id abcd-1234 \ + --tonie-id ef01-5678 \ + --folder ~/Musik/deezer-export/peppa-wutz \ + --playlist-ref "https://www.deezer.com/playlist/XXXXXXXXX" +``` + +Weitere Kommandos: + +```bash +toni-sync config list +toni-sync config remove peppa-wutz +``` + +Konfiguration wird standardmäßig in `~/.config/toni-sync/config.yaml` +gespeichert (überschreibbar via `--config-path` oder `TONI_SYNC_CONFIG`). + +### 3. Synchronisieren + +Lege deine (legal exportierten) Audiodateien in den konfigurierten Ordner, +z. B. `01 - Track.mp3`, `02 - Track.mp3`, ... - die alphabetische +Dateireihenfolge bestimmt die Kapitelreihenfolge auf dem Tonie. + +```bash +# einzelnes Mapping +toni-sync sync peppa-wutz + +# alle Mappings +toni-sync sync --all + +# nur anzeigen, was sich ändern würde +toni-sync sync --all --dry-run +``` + +`sync` lädt neue Dateien hoch, entfernt Kapitel, die lokal nicht mehr +existieren (abschaltbar via `--no-prune` bei `config add`), und sortiert +die Kapitel passend zur lokalen Dateireihenfolge. + +## Unterstützte Audioformate + +`.mp3`, `.m4a`, `.aac`, `.ogg`, `.flac`, `.wav` + +## Entwicklung / Tests + +```bash +pip install -e ".[dev]" +pytest +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f75aa6c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "toni-sync" +version = "0.1.0" +description = "CLI tool to sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies via the TonieCloud API." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +dependencies = [ + "click>=8.1", + "pydantic>=2.0", + "PyYAML>=6.0", + "tonie-api>=0.1.2", +] + +[project.scripts] +toni-sync = "toni_sync.cli:cli" + +[tool.setuptools.packages.find] +where = ["src"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] diff --git a/src/toni_sync/__init__.py b/src/toni_sync/__init__.py new file mode 100644 index 0000000..43c2bed --- /dev/null +++ b/src/toni_sync/__init__.py @@ -0,0 +1,3 @@ +"""toni-sync: Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies.""" + +__version__ = "0.1.0" diff --git a/src/toni_sync/cli.py b/src/toni_sync/cli.py new file mode 100644 index 0000000..005cfdd --- /dev/null +++ b/src/toni_sync/cli.py @@ -0,0 +1,201 @@ +"""Command line interface for toni-sync.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from toni_sync.client import get_client +from toni_sync.config import AppConfig, ToniMapping, load_config, save_config +from toni_sync.sync import apply_plan, build_plan + + +@click.group() +@click.option( + "--config-path", + "config_path", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help="Path to the toni-sync config file (default: ~/.config/toni-sync/config.yaml).", +) +@click.pass_context +def cli(ctx: click.Context, config_path: Path | None) -> None: + """toni-sync: Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies.""" + ctx.ensure_object(dict) + ctx.obj["config_path"] = config_path + + +def _load(ctx: click.Context) -> AppConfig: + return load_config(ctx.obj.get("config_path")) + + +def _save(ctx: click.Context, config: AppConfig) -> None: + save_config(config, ctx.obj.get("config_path")) + + +# -------------------------------------------------------------------------- +# tonies: read-only lookups against the TonieCloud account +# -------------------------------------------------------------------------- + + +@cli.group() +def tonies() -> None: + """Inspect households and Kreativ-Tonies on your TonieCloud account.""" + + +@tonies.command("list") +@click.option("--username", default=None) +@click.option("--password", default=None) +def tonies_list(username: str | None, password: str | None) -> None: + """List all households and Kreativ-Tonies with their ids (for use in `config add`).""" + api = get_client(username, password) + for household in api.get_households(): + click.echo(f"Household: {household.name} [id={household.id}]") + for tonie in api.get_all_creative_tonies_by_household(household): + click.echo( + f" - {tonie.name} [id={tonie.id}] " + f"({tonie.chaptersPresent} chapters, {tonie.secondsPresent:.0f}s)" + ) + + +# -------------------------------------------------------------------------- +# config: manage the local playlist <-> toni mapping +# -------------------------------------------------------------------------- + + +@cli.group() +def config() -> None: + """Manage the mapping between local playlist folders and Kreativ-Tonies.""" + + +@config.command("add") +@click.option("--name", required=True, help="Unique name for this mapping, e.g. 'peppa-wutz'.") +@click.option("--household-id", required=True, help="Household id (see `toni-sync tonies list`).") +@click.option("--tonie-id", required=True, help="Creative Tonie id (see `toni-sync tonies list`).") +@click.option( + "--folder", + required=True, + type=click.Path(file_okay=False, path_type=Path), + help="Local folder containing the exported audio files for this playlist.", +) +@click.option("--tonie-name", default=None, help="Optional friendly name of the tonie (informational).") +@click.option("--playlist-ref", default=None, help="Optional reference to the Deezer playlist (URL/ID).") +@click.option("--no-prune", is_flag=True, help="Never remove chapters that are missing locally.") +@click.pass_context +def config_add( + ctx: click.Context, + name: str, + household_id: str, + tonie_id: str, + folder: Path, + tonie_name: str | None, + playlist_ref: str | None, + no_prune: bool, +) -> None: + """Add or update a playlist/toni mapping.""" + cfg = _load(ctx) + mapping = ToniMapping( + name=name, + household_id=household_id, + tonie_id=tonie_id, + tonie_name=tonie_name, + folder=str(folder), + playlist_ref=playlist_ref, + prune=not no_prune, + ) + cfg.upsert(mapping) + _save(ctx, cfg) + click.echo(f"Saved mapping '{name}' -> tonie {tonie_id} (folder: {folder}).") + + +@config.command("list") +@click.pass_context +def config_list(ctx: click.Context) -> None: + """List all configured mappings.""" + cfg = _load(ctx) + if not cfg.mappings: + click.echo("No mappings configured yet. Use `toni-sync config add`.") + return + for mapping in cfg.mappings: + click.echo( + f"{mapping.name}: folder={mapping.folder} tonie_id={mapping.tonie_id} " + f"household_id={mapping.household_id} prune={mapping.prune}" + ) + + +@config.command("remove") +@click.argument("name") +@click.pass_context +def config_remove(ctx: click.Context, name: str) -> None: + """Remove a mapping by name.""" + cfg = _load(ctx) + if cfg.remove(name): + _save(ctx, cfg) + click.echo(f"Removed mapping '{name}'.") + else: + raise click.ClickException(f"No mapping named '{name}' found.") + + +# -------------------------------------------------------------------------- +# sync: upload/prune/reorder based on the local folder contents +# -------------------------------------------------------------------------- + + +@cli.command() +@click.argument("name", required=False) +@click.option("--all", "sync_all", is_flag=True, help="Sync all configured mappings.") +@click.option("--dry-run", is_flag=True, help="Only show what would change, without uploading anything.") +@click.option("--username", default=None) +@click.option("--password", default=None) +@click.pass_context +def sync( + ctx: click.Context, + name: str | None, + sync_all: bool, + dry_run: bool, + username: str | None, + password: str | None, +) -> None: + """Sync a local playlist folder to its Kreativ-Tonie. + + Uploads new files, removes chapters no longer present locally + (unless the mapping has pruning disabled), and reorders chapters + to match the local folder's file order. + """ + cfg = _load(ctx) + if not sync_all and not name: + raise click.ClickException("Specify a mapping NAME or use --all.") + + targets = cfg.mappings if sync_all else [m for m in cfg.mappings if m.name == name] + if not targets: + raise click.ClickException(f"No mapping named '{name}' found.") + + api = get_client(username, password) + + for mapping in targets: + click.echo(f"== {mapping.name} ==") + plan = build_plan(api, mapping) + + if plan.to_upload: + click.echo(f" Upload ({len(plan.to_upload)}):") + for track in plan.to_upload: + click.echo(f" + {track.name}") + if plan.to_remove: + click.echo(f" Remove ({len(plan.to_remove)}):") + for chapter in plan.to_remove: + click.echo(f" - {chapter.title}") + if not plan.needs_changes: + click.echo(" Already up to date.") + continue + + if dry_run: + click.echo(" (dry-run, no changes applied)") + continue + + apply_plan(api, mapping, plan) + click.echo(" Done.") + + +if __name__ == "__main__": + cli() diff --git a/src/toni_sync/client.py b/src/toni_sync/client.py new file mode 100644 index 0000000..c39ffdb --- /dev/null +++ b/src/toni_sync/client.py @@ -0,0 +1,47 @@ +"""Thin wrapper around tonie_api.TonieAPI handling credential resolution.""" + +from __future__ import annotations + +import os + +import click +from tonie_api.api import TonieAPI +from tonie_api.models import CreativeTonie, Household + + +class TonieAuthError(RuntimeError): + """Raised when authentication with the TonieCloud fails.""" + + +def get_client(username: str | None = None, password: str | None = None) -> TonieAPI: + """Create an authenticated TonieAPI client. + + Credential resolution order: + 1. Explicit function arguments (e.g. --username/--password CLI flags) + 2. Environment variables TONI_SYNC_USERNAME / TONI_SYNC_PASSWORD + 3. Interactive prompt + """ + username = username or os.environ.get("TONI_SYNC_USERNAME") or click.prompt("TonieCloud username (email)") + password = password or os.environ.get("TONI_SYNC_PASSWORD") or click.prompt( + "TonieCloud password", hide_input=True + ) + try: + return TonieAPI(username=username, password=password) + except ValueError as exc: + raise TonieAuthError(str(exc)) from exc + + +def find_household(api: TonieAPI, household_id: str) -> Household: + for household in api.get_households(): + if household.id == household_id: + return household + msg = f"Household with id {household_id!r} not found for this account." + raise ValueError(msg) + + +def find_creative_tonie(api: TonieAPI, tonie_id: str) -> CreativeTonie: + for tonie in api.get_all_creative_tonies(): + if tonie.id == tonie_id: + return tonie + msg = f"Creative Tonie with id {tonie_id!r} not found for this account." + raise ValueError(msg) diff --git a/src/toni_sync/config.py b/src/toni_sync/config.py new file mode 100644 index 0000000..339c68e --- /dev/null +++ b/src/toni_sync/config.py @@ -0,0 +1,72 @@ +"""Configuration handling for toni-sync. + +Stores the mapping between local audio folders (e.g. exported Deezer +playlists) and Kreativ-Tonies in a simple YAML file. Credentials for the +TonieCloud account are NEVER stored here - they are provided via +environment variables or interactive prompt (see client.py). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field + +DEFAULT_CONFIG_PATH = Path( + os.environ.get("TONI_SYNC_CONFIG", str(Path.home() / ".config" / "toni-sync" / "config.yaml")) +) + + +class ToniMapping(BaseModel): + """Mapping of a local folder to a Kreativ-Tonie.""" + + name: str # unique friendly identifier used on the CLI, e.g. "peppa-wutz" + household_id: str + tonie_id: str + tonie_name: str | None = None # informational, kept in sync on config add/list + folder: str # local path containing the audio files for this tonie + playlist_ref: str | None = None # informational, e.g. deezer playlist URL/ID + prune: bool = True # remove chapters on the tonie that no longer exist locally + + @property + def folder_path(self) -> Path: + return Path(self.folder).expanduser() + + +class AppConfig(BaseModel): + """The full toni-sync configuration.""" + + mappings: list[ToniMapping] = Field(default_factory=list) + + def get(self, name: str) -> ToniMapping | None: + return next((m for m in self.mappings if m.name == name), None) + + def upsert(self, mapping: ToniMapping) -> None: + for i, existing in enumerate(self.mappings): + if existing.name == mapping.name: + self.mappings[i] = mapping + return + self.mappings.append(mapping) + + def remove(self, name: str) -> bool: + before = len(self.mappings) + self.mappings = [m for m in self.mappings if m.name != name] + return len(self.mappings) != before + + +def load_config(path: Path | None = None) -> AppConfig: + path = path or DEFAULT_CONFIG_PATH + if not path.exists(): + return AppConfig() + with path.open("r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + return AppConfig(**raw) + + +def save_config(config: AppConfig, path: Path | None = None) -> None: + path = path or DEFAULT_CONFIG_PATH + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as fh: + yaml.safe_dump(config.model_dump(), fh, allow_unicode=True, sort_keys=False) diff --git a/src/toni_sync/sync.py b/src/toni_sync/sync.py new file mode 100644 index 0000000..0b972b1 --- /dev/null +++ b/src/toni_sync/sync.py @@ -0,0 +1,91 @@ +"""Core sync logic: diff a local audio folder against a Kreativ-Tonie's chapters.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from tonie_api.api import TonieAPI +from tonie_api.models import Chapter, CreativeTonie + +from toni_sync.client import find_creative_tonie +from toni_sync.config import ToniMapping + +AUDIO_EXTENSIONS = {".mp3", ".m4a", ".aac", ".ogg", ".flac", ".wav"} +MAX_TITLE_LENGTH = 100 + + +def title_from_filename(path: Path) -> str: + """Derive a chapter title from a filename (its stem, length-capped).""" + title = path.stem.strip() + return title[:MAX_TITLE_LENGTH] + + +def list_local_tracks(folder: Path) -> list[Path]: + """Return audio files in `folder`, sorted by filename (defines chapter order).""" + if not folder.is_dir(): + msg = f"Folder does not exist: {folder}" + raise FileNotFoundError(msg) + return sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS) + + +@dataclass +class SyncPlan: + tonie: CreativeTonie + local_tracks: list[Path] + to_upload: list[Path] = field(default_factory=list) + to_remove: list[Chapter] = field(default_factory=list) + final_order_titles: list[str] = field(default_factory=list) + + @property + def needs_changes(self) -> bool: + return bool(self.to_upload or self.to_remove) or self._order_changed() + + def _order_changed(self) -> bool: + current_titles = [c.title for c in self.tonie.chapters] + kept_titles = [t for t in current_titles if t not in {c.title for c in self.to_remove}] + return kept_titles != self.final_order_titles[: len(kept_titles)] + + +def build_plan(api: TonieAPI, mapping: ToniMapping) -> SyncPlan: + tonie = find_creative_tonie(api, mapping.tonie_id) + local_tracks = list_local_tracks(mapping.folder_path) + local_titles = [title_from_filename(p) for p in local_tracks] + + existing_titles = [c.title for c in tonie.chapters] + + to_upload = [p for p, t in zip(local_tracks, local_titles) if t not in existing_titles] + to_remove = ( + [c for c in tonie.chapters if c.title not in local_titles] if mapping.prune else [] + ) + + return SyncPlan( + tonie=tonie, + local_tracks=local_tracks, + to_upload=to_upload, + to_remove=to_remove, + final_order_titles=local_titles if mapping.prune else local_titles + [ + c.title for c in tonie.chapters if c.title not in local_titles + ], + ) + + +def apply_plan(api: TonieAPI, mapping: ToniMapping, plan: SyncPlan) -> None: + """Upload missing files, then reorder/prune chapters to match the local folder.""" + local_titles = [title_from_filename(p) for p in plan.local_tracks] + + for track in plan.to_upload: + title = title_from_filename(track) + api.upload_file_to_tonie(plan.tonie, track, title) + + # Refetch to get up-to-date chapter list (incl. newly uploaded files' ids/tokens). + refreshed = find_creative_tonie(api, mapping.tonie_id) + chapters_by_title = {c.title: c for c in refreshed.chapters} + + ordered_chapters = [chapters_by_title[t] for t in plan.final_order_titles if t in chapters_by_title] + + # Only issue a reorder/prune call if something actually differs from current state. + current_order = [c.title for c in refreshed.chapters] + desired_order = [c.title for c in ordered_chapters] + if current_order != desired_order: + api.sort_chapter_of_tonie(refreshed, ordered_chapters) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..43ab315 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from toni_sync.config import AppConfig, ToniMapping, load_config, save_config + + +def test_upsert_adds_new_mapping(): + cfg = AppConfig() + m = ToniMapping(name="a", household_id="h1", tonie_id="t1", folder="/tmp/a") + cfg.upsert(m) + assert cfg.get("a") == m + + +def test_upsert_replaces_existing_mapping(): + cfg = AppConfig() + cfg.upsert(ToniMapping(name="a", household_id="h1", tonie_id="t1", folder="/tmp/a")) + updated = ToniMapping(name="a", household_id="h1", tonie_id="t2", folder="/tmp/a2") + cfg.upsert(updated) + assert len(cfg.mappings) == 1 + assert cfg.get("a").tonie_id == "t2" + + +def test_remove_returns_false_when_missing(): + cfg = AppConfig() + assert cfg.remove("nope") is False + + +def test_save_and_load_roundtrip(tmp_path: Path): + path = tmp_path / "config.yaml" + cfg = AppConfig() + cfg.upsert( + ToniMapping( + name="peppa", + household_id="h1", + tonie_id="t1", + folder="/tmp/peppa", + playlist_ref="https://example.com/playlist/1", + ) + ) + save_config(cfg, path) + + loaded = load_config(path) + assert loaded.get("peppa").tonie_id == "t1" + assert loaded.get("peppa").playlist_ref == "https://example.com/playlist/1" + + +def test_load_missing_file_returns_empty_config(tmp_path: Path): + cfg = load_config(tmp_path / "does-not-exist.yaml") + assert cfg.mappings == [] diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..4723d5d --- /dev/null +++ b/tests/test_sync.py @@ -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"]