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