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,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)
|
||||
Reference in New Issue
Block a user