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
}
+74
View File
@@ -0,0 +1,74 @@
package config
import (
"path/filepath"
"testing"
)
func TestUpsertAddsNewMapping(t *testing.T) {
cfg := &Config{}
m := Mapping{Name: "a", HouseholdID: "h1", TonieID: "t1", Folder: "/tmp/a"}
cfg.Upsert(m)
got := cfg.Get("a")
if got == nil || got.TonieID != "t1" {
t.Fatalf("expected mapping to be added, got %+v", got)
}
}
func TestUpsertReplacesExistingMapping(t *testing.T) {
cfg := &Config{}
cfg.Upsert(Mapping{Name: "a", HouseholdID: "h1", TonieID: "t1", Folder: "/tmp/a"})
cfg.Upsert(Mapping{Name: "a", HouseholdID: "h1", TonieID: "t2", Folder: "/tmp/a2"})
if len(cfg.Mappings) != 1 {
t.Fatalf("expected 1 mapping, got %d", len(cfg.Mappings))
}
if cfg.Get("a").TonieID != "t2" {
t.Fatalf("expected updated tonie id t2, got %s", cfg.Get("a").TonieID)
}
}
func TestRemoveReturnsFalseWhenMissing(t *testing.T) {
cfg := &Config{}
if cfg.Remove("nope") {
t.Fatal("expected Remove to return false for missing mapping")
}
}
func TestSaveAndLoadRoundtrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
cfg := &Config{}
cfg.Upsert(Mapping{
Name: "peppa",
HouseholdID: "h1",
TonieID: "t1",
Folder: "/tmp/peppa",
PlaylistRef: "https://example.com/playlist/1",
Prune: true,
})
if err := Save(cfg, path); err != nil {
t.Fatalf("Save failed: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
m := loaded.Get("peppa")
if m == nil || m.TonieID != "t1" || m.PlaylistRef != "https://example.com/playlist/1" {
t.Fatalf("unexpected loaded mapping: %+v", m)
}
}
func TestLoadMissingFileReturnsEmptyConfig(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if len(cfg.Mappings) != 0 {
t.Fatalf("expected empty mappings, got %+v", cfg.Mappings)
}
}