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
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.egg-info/
.venv/
venv/
build/
dist/
.pytest_cache/
.coverage
+101
View File
@@ -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
```
+28
View File
@@ -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",
]
+3
View File
@@ -0,0 +1,3 @@
"""toni-sync: Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies."""
__version__ = "0.1.0"
+201
View File
@@ -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()
+47
View File
@@ -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)
+72
View File
@@ -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)
+91
View File
@@ -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)
+48
View File
@@ -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 == []
+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"]