Rewrite toni-sync in Go

- Replace the Python implementation with a Go module compiling to a
  single static binary (no Python/pip runtime dependency)
- internal/tonieapi: minimal, dependency-light HTTP client for the
  TonieCloud REST API (login, households, creative tonies, file
  upload via presigned S3 request, chapter add/sort/clear)
- internal/config: YAML-based mapping of local playlist folders to
  Kreativ-Tonies, credentials never stored
- internal/syncer: diff/apply logic (upload new tracks, prune removed
  chapters, reorder to match local file order), built against a
  TonieClient interface for testability
- cmd/toni-sync: Cobra CLI with `tonies list`, `config add/list/remove`,
  `sync [NAME|--all] [--dry-run]`
- Unit tests for config persistence and syncer plan/apply logic

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-14 06:52:11 +02:00
co-authored by Copilot
parent 4860edf886
commit d003c32433
23 changed files with 1278 additions and 631 deletions
+102
View File
@@ -0,0 +1,102 @@
// Package config manages the local mapping between playlist folders and
// Kreativ-Tonies. Credentials are never stored here.
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Mapping maps a local folder (e.g. an exported Deezer playlist) to a Kreativ-Tonie.
type Mapping struct {
Name string `yaml:"name"`
HouseholdID string `yaml:"household_id"`
TonieID string `yaml:"tonie_id"`
TonieName string `yaml:"tonie_name,omitempty"`
Folder string `yaml:"folder"`
PlaylistRef string `yaml:"playlist_ref,omitempty"`
Prune bool `yaml:"prune"`
}
// Config is the full toni-sync configuration.
type Config struct {
Mappings []Mapping `yaml:"mappings"`
}
// Get returns the mapping with the given name, if present.
func (c *Config) Get(name string) *Mapping {
for i := range c.Mappings {
if c.Mappings[i].Name == name {
return &c.Mappings[i]
}
}
return nil
}
// Upsert adds a new mapping or replaces an existing one with the same name.
func (c *Config) Upsert(m Mapping) {
for i := range c.Mappings {
if c.Mappings[i].Name == m.Name {
c.Mappings[i] = m
return
}
}
c.Mappings = append(c.Mappings, m)
}
// Remove deletes the mapping with the given name. Returns false if it was not found.
func (c *Config) Remove(name string) bool {
for i := range c.Mappings {
if c.Mappings[i].Name == name {
c.Mappings = append(c.Mappings[:i], c.Mappings[i+1:]...)
return true
}
}
return false
}
// DefaultPath returns the default config file location, honoring TONI_SYNC_CONFIG.
func DefaultPath() string {
if p := os.Getenv("TONI_SYNC_CONFIG"); p != "" {
return p
}
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".config", "toni-sync", "config.yaml")
}
// Load reads the config from path, or returns an empty Config if it doesn't exist yet.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{}, nil
}
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config %s: %w", path, err)
}
return &cfg, nil
}
// Save writes the config to path, creating parent directories as needed.
func Save(cfg *Config, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("creating config dir: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("encoding config: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("writing config %s: %w", path, err)
}
return nil
}