From d003c324330ba1d246f0dca51d45a13e2fd3e269 Mon Sep 17 00:00:00 2001 From: arnef Date: Fri, 14 Aug 2026 06:52:11 +0200 Subject: [PATCH] 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> --- .gitignore | 10 +- README.md | 33 ++++- cmd/toni-sync/auth.go | 50 +++++++ cmd/toni-sync/config.go | 117 +++++++++++++++ cmd/toni-sync/main.go | 27 ++++ cmd/toni-sync/sync.go | 104 ++++++++++++++ cmd/toni-sync/tonies.go | 52 +++++++ go.mod | 15 ++ go.sum | 17 +++ internal/config/config.go | 102 ++++++++++++++ internal/config/config_test.go | 74 ++++++++++ internal/syncer/syncer.go | 215 ++++++++++++++++++++++++++++ internal/syncer/syncer_test.go | 177 +++++++++++++++++++++++ internal/tonieapi/client.go | 250 +++++++++++++++++++++++++++++++++ internal/tonieapi/models.go | 49 +++++++ pyproject.toml | 28 ---- src/toni_sync/__init__.py | 3 - src/toni_sync/cli.py | 201 -------------------------- src/toni_sync/client.py | 47 ------- src/toni_sync/config.py | 72 ---------- src/toni_sync/sync.py | 91 ------------ tests/test_config.py | 48 ------- tests/test_sync.py | 127 ----------------- 23 files changed, 1278 insertions(+), 631 deletions(-) create mode 100644 cmd/toni-sync/auth.go create mode 100644 cmd/toni-sync/config.go create mode 100644 cmd/toni-sync/main.go create mode 100644 cmd/toni-sync/sync.go create mode 100644 cmd/toni-sync/tonies.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/syncer/syncer.go create mode 100644 internal/syncer/syncer_test.go create mode 100644 internal/tonieapi/client.go create mode 100644 internal/tonieapi/models.go delete mode 100644 pyproject.toml delete mode 100644 src/toni_sync/__init__.py delete mode 100644 src/toni_sync/cli.py delete mode 100644 src/toni_sync/client.py delete mode 100644 src/toni_sync/config.py delete mode 100644 src/toni_sync/sync.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_sync.py diff --git a/.gitignore b/.gitignore index 2e9f706..a0650c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,3 @@ -__pycache__/ -*.pyc -*.egg-info/ -.venv/ -venv/ -build/ +/toni-sync +*.exe dist/ -.pytest_cache/ -.coverage diff --git a/README.md b/README.md index bfe101e..77f8148 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ 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/)). +passenden Kreativ-Tonies über die inoffizielle TonieCloud-API. Geschrieben +in Go, kompiliert zu einer einzigen statischen Binary - keine Laufzeit- +Abhängigkeiten (kein Python/pip nötig). > **Hinweis:** toni-sync lädt selbst keine Musik von Deezer herunter. Das > Herunterladen/Umgehen von DRM-geschützten Streams verstößt gegen die @@ -14,8 +15,18 @@ passenden Kreativ-Tonies über die inoffizielle TonieCloud-API ## Installation +Benötigt wird nur ein Go-Toolchain (>= 1.21) zum Bauen - danach ist das +Ergebnis eine einzelne Binary ohne weitere Abhängigkeiten: + ```bash -pip install -e . +go build -o toni-sync ./cmd/toni-sync +./toni-sync --help +``` + +Oder direkt installieren (landet in `$(go env GOPATH)/bin`): + +```bash +go install ./cmd/toni-sync ``` ## Anmeldedaten @@ -29,7 +40,7 @@ export TONI_SYNC_USERNAME="you@example.com" export TONI_SYNC_PASSWORD="********" ``` -Alternativ: `--username`/`--password` Optionen bei `tonies list` und `sync`. +Alternativ: `--username`/`--password` Flags bei `tonies list` und `sync`. ## Nutzung @@ -93,9 +104,19 @@ die Kapitel passend zur lokalen Dateireihenfolge. `.mp3`, `.m4a`, `.aac`, `.ogg`, `.flac`, `.wav` +## Projektstruktur + +``` +cmd/toni-sync/ CLI-Einstiegspunkt (Cobra-Kommandos) +internal/tonieapi/ Eigener, minimaler TonieCloud-API-Client +internal/config/ Laden/Speichern der Playlist<->Toni-Mappings (YAML) +internal/syncer/ Diff-/Apply-Logik zwischen lokalem Ordner und Tonie +``` + ## Entwicklung / Tests ```bash -pip install -e ".[dev]" -pytest +go build ./... +go vet ./... +go test ./... ``` diff --git a/cmd/toni-sync/auth.go b/cmd/toni-sync/auth.go new file mode 100644 index 0000000..c126dde --- /dev/null +++ b/cmd/toni-sync/auth.go @@ -0,0 +1,50 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/arnef/toni-sync/internal/tonieapi" + "golang.org/x/term" +) + +// resolveClient builds an authenticated TonieCloud client, resolving credentials +// from flags, then environment variables, then an interactive prompt. +func resolveClient(username, password string) (*tonieapi.Client, error) { + if username == "" { + username = os.Getenv("TONI_SYNC_USERNAME") + } + if username == "" { + username = prompt("TonieCloud username (email): ") + } + + if password == "" { + password = os.Getenv("TONI_SYNC_PASSWORD") + } + if password == "" { + password = promptPassword("TonieCloud password: ") + } + + return tonieapi.NewClient(username, password) +} + +func prompt(label string) string { + fmt.Fprint(os.Stderr, label) + reader := bufio.NewReader(os.Stdin) + line, _ := reader.ReadString('\n') + return strings.TrimSpace(line) +} + +func promptPassword(label string) string { + fmt.Fprint(os.Stderr, label) + if term.IsTerminal(int(os.Stdin.Fd())) { + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err == nil { + return strings.TrimSpace(string(b)) + } + } + return prompt("") +} diff --git a/cmd/toni-sync/config.go b/cmd/toni-sync/config.go new file mode 100644 index 0000000..fa8e11a --- /dev/null +++ b/cmd/toni-sync/config.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/arnef/toni-sync/internal/config" +) + +func loadConfig() (*config.Config, string, error) { + path := configPath + if path == "" { + path = config.DefaultPath() + } + cfg, err := config.Load(path) + return cfg, path, err +} + +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage the mapping between local playlist folders and Kreativ-Tonies.", + } + cmd.AddCommand(newConfigAddCmd()) + cmd.AddCommand(newConfigListCmd()) + cmd.AddCommand(newConfigRemoveCmd()) + return cmd +} + +func newConfigAddCmd() *cobra.Command { + var name, householdID, tonieID, folder, tonieName, playlistRef string + var noPrune bool + + cmd := &cobra.Command{ + Use: "add", + Short: "Add or update a playlist/toni mapping.", + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" || householdID == "" || tonieID == "" || folder == "" { + return fmt.Errorf("--name, --household-id, --tonie-id and --folder are required") + } + + cfg, path, err := loadConfig() + if err != nil { + return err + } + + cfg.Upsert(config.Mapping{ + Name: name, + HouseholdID: householdID, + TonieID: tonieID, + TonieName: tonieName, + Folder: folder, + PlaylistRef: playlistRef, + Prune: !noPrune, + }) + + if err := config.Save(cfg, path); err != nil { + return err + } + fmt.Printf("Saved mapping '%s' -> tonie %s (folder: %s).\n", name, tonieID, folder) + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Unique name for this mapping, e.g. 'peppa-wutz'.") + cmd.Flags().StringVar(&householdID, "household-id", "", "Household id (see `toni-sync tonies list`).") + cmd.Flags().StringVar(&tonieID, "tonie-id", "", "Creative Tonie id (see `toni-sync tonies list`).") + cmd.Flags().StringVar(&folder, "folder", "", "Local folder containing the exported audio files for this playlist.") + cmd.Flags().StringVar(&tonieName, "tonie-name", "", "Optional friendly name of the tonie (informational).") + cmd.Flags().StringVar(&playlistRef, "playlist-ref", "", "Optional reference to the Deezer playlist (URL/ID).") + cmd.Flags().BoolVar(&noPrune, "no-prune", false, "Never remove chapters that are missing locally.") + return cmd +} + +func newConfigListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all configured mappings.", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, err := loadConfig() + if err != nil { + return err + } + if len(cfg.Mappings) == 0 { + fmt.Println("No mappings configured yet. Use `toni-sync config add`.") + return nil + } + for _, m := range cfg.Mappings { + fmt.Printf("%s: folder=%s tonie_id=%s household_id=%s prune=%v\n", m.Name, m.Folder, m.TonieID, m.HouseholdID, m.Prune) + } + return nil + }, + } +} + +func newConfigRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove NAME", + Short: "Remove a mapping by name.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, path, err := loadConfig() + if err != nil { + return err + } + if !cfg.Remove(args[0]) { + return fmt.Errorf("no mapping named '%s' found", args[0]) + } + if err := config.Save(cfg, path); err != nil { + return err + } + fmt.Printf("Removed mapping '%s'.\n", args[0]) + return nil + }, + } +} diff --git a/cmd/toni-sync/main.go b/cmd/toni-sync/main.go new file mode 100644 index 0000000..46f10aa --- /dev/null +++ b/cmd/toni-sync/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var configPath string + +func main() { + rootCmd := &cobra.Command{ + Use: "toni-sync", + Short: "Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies.", + } + rootCmd.PersistentFlags().StringVar(&configPath, "config-path", "", "Path to the toni-sync config file (default: ~/.config/toni-sync/config.yaml)") + + rootCmd.AddCommand(newToniesCmd()) + rootCmd.AddCommand(newConfigCmd()) + rootCmd.AddCommand(newSyncCmd()) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} diff --git a/cmd/toni-sync/sync.go b/cmd/toni-sync/sync.go new file mode 100644 index 0000000..9d69275 --- /dev/null +++ b/cmd/toni-sync/sync.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/arnef/toni-sync/internal/config" + "github.com/arnef/toni-sync/internal/syncer" + "github.com/arnef/toni-sync/internal/tonieapi" +) + +func newSyncCmd() *cobra.Command { + var syncAll, dryRun bool + var username, password string + + cmd := &cobra.Command{ + Use: "sync [NAME]", + Short: "Sync a local playlist folder to its Kreativ-Tonie.", + Long: "Uploads new files, removes chapters no longer present locally\n" + + "(unless the mapping has pruning disabled), and reorders chapters\n" + + "to match the local folder's file order.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var name string + if len(args) == 1 { + name = args[0] + } + if !syncAll && name == "" { + return fmt.Errorf("specify a mapping NAME or use --all") + } + + cfg, _, err := loadConfig() + if err != nil { + return err + } + + var targets []config.Mapping + if syncAll { + targets = cfg.Mappings + } else if m := cfg.Get(name); m != nil { + targets = []config.Mapping{*m} + } + if len(targets) == 0 { + return fmt.Errorf("no mapping named '%s' found", name) + } + + client, err := resolveClient(username, password) + if err != nil { + return err + } + + for _, m := range targets { + if err := syncOne(client, m, dryRun); err != nil { + return fmt.Errorf("syncing '%s': %w", m.Name, err) + } + } + return nil + }, + } + + cmd.Flags().BoolVar(&syncAll, "all", false, "Sync all configured mappings.") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would change, without uploading anything.") + cmd.Flags().StringVar(&username, "username", "", "TonieCloud username (default: env TONI_SYNC_USERNAME or prompt)") + cmd.Flags().StringVar(&password, "password", "", "TonieCloud password (default: env TONI_SYNC_PASSWORD or prompt)") + return cmd +} + +func syncOne(client *tonieapi.Client, m config.Mapping, dryRun bool) error { + fmt.Printf("== %s ==\n", m.Name) + + plan, err := syncer.BuildPlan(client, m) + if err != nil { + return err + } + + if len(plan.ToUpload) > 0 { + fmt.Printf(" Upload (%d):\n", len(plan.ToUpload)) + for _, t := range plan.ToUpload { + fmt.Printf(" + %s\n", t.Title) + } + } + if len(plan.ToRemove) > 0 { + fmt.Printf(" Remove (%d):\n", len(plan.ToRemove)) + for _, c := range plan.ToRemove { + fmt.Printf(" - %s\n", c.Title) + } + } + if !plan.NeedsChanges() { + fmt.Println(" Already up to date.") + return nil + } + + if dryRun { + fmt.Println(" (dry-run, no changes applied)") + return nil + } + + if err := syncer.ApplyPlan(client, m, plan); err != nil { + return err + } + fmt.Println(" Done.") + return nil +} diff --git a/cmd/toni-sync/tonies.go b/cmd/toni-sync/tonies.go new file mode 100644 index 0000000..26a8c8d --- /dev/null +++ b/cmd/toni-sync/tonies.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newToniesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tonies", + Short: "Inspect households and Kreativ-Tonies on your TonieCloud account.", + } + cmd.AddCommand(newToniesListCmd()) + return cmd +} + +func newToniesListCmd() *cobra.Command { + var username, password string + + cmd := &cobra.Command{ + Use: "list", + Short: "List all households and Kreativ-Tonies with their ids (for use in `config add`).", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := resolveClient(username, password) + if err != nil { + return err + } + + households, err := client.GetHouseholds() + if err != nil { + return err + } + + for _, h := range households { + fmt.Printf("Household: %s [id=%s]\n", h.Name, h.ID) + tonies, err := client.GetCreativeTonies(h.ID) + if err != nil { + return err + } + for _, t := range tonies { + fmt.Printf(" - %s [id=%s] (%d chapters, %.0fs)\n", t.Name, t.ID, t.ChaptersPresent, t.SecondsPresent) + } + } + return nil + }, + } + + cmd.Flags().StringVar(&username, "username", "", "TonieCloud username (default: env TONI_SYNC_USERNAME or prompt)") + cmd.Flags().StringVar(&password, "password", "", "TonieCloud password (default: env TONI_SYNC_PASSWORD or prompt)") + return cmd +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c5d105c --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/arnef/toni-sync + +go 1.26.4 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.45.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..37f8f68 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a080491 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..568bf8a --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go new file mode 100644 index 0000000..8db18b0 --- /dev/null +++ b/internal/syncer/syncer.go @@ -0,0 +1,215 @@ +// Package syncer contains the diff/apply logic that reconciles a local +// audio folder with the chapters of a Kreativ-Tonie. +package syncer + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/arnef/toni-sync/internal/config" + "github.com/arnef/toni-sync/internal/tonieapi" +) + +// TonieClient is the subset of tonieapi.Client used by the syncer, extracted +// as an interface so it can be faked in tests. +type TonieClient interface { + GetCreativeTonie(tonieID string) (*tonieapi.CreativeTonie, error) + UploadFileToTonie(tonie tonieapi.CreativeTonie, filePath, title string) error + SortChaptersOfTonie(tonie tonieapi.CreativeTonie, chapters []tonieapi.Chapter) error +} + +const maxTitleLength = 100 + +var audioExtensions = map[string]bool{ + ".mp3": true, + ".m4a": true, + ".aac": true, + ".ogg": true, + ".flac": true, + ".wav": true, +} + +// Track is a local audio file considered for syncing. +type Track struct { + Path string + Title string +} + +// TitleFromFilename derives a chapter title from a filename (its stem, length-capped). +func TitleFromFilename(name string) string { + title := strings.TrimSuffix(name, filepath.Ext(name)) + title = strings.TrimSpace(title) + if len(title) > maxTitleLength { + title = title[:maxTitleLength] + } + return title +} + +// ListLocalTracks returns audio files in folder, sorted by filename (defines chapter order). +func ListLocalTracks(folder string) ([]Track, error) { + entries, err := os.ReadDir(folder) + if err != nil { + return nil, fmt.Errorf("folder does not exist: %s: %w", folder, err) + } + + var tracks []Track + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if !audioExtensions[ext] { + continue + } + tracks = append(tracks, Track{ + Path: filepath.Join(folder, e.Name()), + Title: TitleFromFilename(e.Name()), + }) + } + sort.Slice(tracks, func(i, j int) bool { return tracks[i].Path < tracks[j].Path }) + return tracks, nil +} + +// Plan describes the changes needed to bring a Kreativ-Tonie in sync with a local folder. +type Plan struct { + Tonie tonieapi.CreativeTonie + LocalTracks []Track + ToUpload []Track + ToRemove []tonieapi.Chapter + FinalOrderTitles []string +} + +// NeedsChanges reports whether applying the plan would change anything on the tonie. +func (p *Plan) NeedsChanges() bool { + if len(p.ToUpload) > 0 || len(p.ToRemove) > 0 { + return true + } + removed := make(map[string]bool, len(p.ToRemove)) + for _, c := range p.ToRemove { + removed[c.Title] = true + } + var kept []string + for _, c := range p.Tonie.Chapters { + if !removed[c.Title] { + kept = append(kept, c.Title) + } + } + limit := len(kept) + if limit > len(p.FinalOrderTitles) { + limit = len(p.FinalOrderTitles) + } + for i := 0; i < limit; i++ { + if kept[i] != p.FinalOrderTitles[i] { + return true + } + } + return len(kept) != len(p.FinalOrderTitles) +} + +// BuildPlan fetches the current state of the tonie and computes the diff against the local folder. +func BuildPlan(client TonieClient, m config.Mapping) (*Plan, error) { + tonie, err := client.GetCreativeTonie(m.TonieID) + if err != nil { + return nil, err + } + + tracks, err := ListLocalTracks(m.Folder) + if err != nil { + return nil, err + } + + localTitles := make(map[string]bool, len(tracks)) + orderedTitles := make([]string, 0, len(tracks)) + for _, t := range tracks { + localTitles[t.Title] = true + orderedTitles = append(orderedTitles, t.Title) + } + + existingTitles := make(map[string]bool, len(tonie.Chapters)) + for _, c := range tonie.Chapters { + existingTitles[c.Title] = true + } + + var toUpload []Track + for _, t := range tracks { + if !existingTitles[t.Title] { + toUpload = append(toUpload, t) + } + } + + var toRemove []tonieapi.Chapter + finalOrder := append([]string{}, orderedTitles...) + if m.Prune { + for _, c := range tonie.Chapters { + if !localTitles[c.Title] { + toRemove = append(toRemove, c) + } + } + } else { + for _, c := range tonie.Chapters { + if !localTitles[c.Title] { + finalOrder = append(finalOrder, c.Title) + } + } + } + + return &Plan{ + Tonie: *tonie, + LocalTracks: tracks, + ToUpload: toUpload, + ToRemove: toRemove, + FinalOrderTitles: finalOrder, + }, nil +} + +// ApplyPlan uploads missing tracks, then reorders/prunes chapters to match the local folder. +func ApplyPlan(client TonieClient, m config.Mapping, plan *Plan) error { + for _, track := range plan.ToUpload { + if err := client.UploadFileToTonie(plan.Tonie, track.Path, track.Title); err != nil { + return fmt.Errorf("uploading %s: %w", track.Path, err) + } + } + + refreshed, err := client.GetCreativeTonie(m.TonieID) + if err != nil { + return fmt.Errorf("refetching tonie after upload: %w", err) + } + + chaptersByTitle := make(map[string]tonieapi.Chapter, len(refreshed.Chapters)) + currentOrder := make([]string, 0, len(refreshed.Chapters)) + for _, c := range refreshed.Chapters { + chaptersByTitle[c.Title] = c + currentOrder = append(currentOrder, c.Title) + } + + var ordered []tonieapi.Chapter + var desiredOrder []string + for _, title := range plan.FinalOrderTitles { + if c, ok := chaptersByTitle[title]; ok { + ordered = append(ordered, c) + desiredOrder = append(desiredOrder, title) + } + } + + if !equalStrings(currentOrder, desiredOrder) { + if err := client.SortChaptersOfTonie(*refreshed, ordered); err != nil { + return fmt.Errorf("reordering chapters: %w", err) + } + } + return nil +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go new file mode 100644 index 0000000..d8cd6ba --- /dev/null +++ b/internal/syncer/syncer_test.go @@ -0,0 +1,177 @@ +package syncer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/arnef/toni-sync/internal/config" + "github.com/arnef/toni-sync/internal/tonieapi" +) + +// fakeClient is a minimal in-memory stand-in for tonieapi.Client used in tests. +type fakeClient struct { + tonieSequence []tonieapi.CreativeTonie // consumed in order by GetCreativeTonie calls + callIndex int + + uploadedTitles []string + sortedTitles []string +} + +func (f *fakeClient) GetCreativeTonie(tonieID string) (*tonieapi.CreativeTonie, error) { + t := f.tonieSequence[f.callIndex] + if f.callIndex < len(f.tonieSequence)-1 { + f.callIndex++ + } + return &t, nil +} + +func (f *fakeClient) UploadFileToTonie(tonie tonieapi.CreativeTonie, filePath, title string) error { + f.uploadedTitles = append(f.uploadedTitles, title) + return nil +} + +func (f *fakeClient) SortChaptersOfTonie(tonie tonieapi.CreativeTonie, chapters []tonieapi.Chapter) error { + for _, c := range chapters { + f.sortedTitles = append(f.sortedTitles, c.Title) + } + return nil +} + +func makeTonie(chapters []tonieapi.Chapter) tonieapi.CreativeTonie { + return tonieapi.CreativeTonie{ID: "t1", HouseholdID: "h1", Name: "Test Tonie", Chapters: chapters} +} + +func setupFolder(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "01 - First.mp3"), []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "02 - Second.mp3"), []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestListLocalTracksFiltersByExtension(t *testing.T) { + dir := setupFolder(t) + tracks, err := ListLocalTracks(dir) + if err != nil { + t.Fatal(err) + } + if len(tracks) != 2 || tracks[0].Title != "01 - First" || tracks[1].Title != "02 - Second" { + t.Fatalf("unexpected tracks: %+v", tracks) + } +} + +func TestListLocalTracksMissingFolder(t *testing.T) { + if _, err := ListLocalTracks(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected error for missing folder") + } +} + +func TestBuildPlanDetectsUploadNoRemoval(t *testing.T) { + dir := setupFolder(t) + tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}}) + client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}} + + m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true} + plan, err := BuildPlan(client, m) + if err != nil { + t.Fatal(err) + } + + if len(plan.ToUpload) != 1 || plan.ToUpload[0].Title != "02 - Second" { + t.Fatalf("expected upload of '02 - Second', got %+v", plan.ToUpload) + } + if len(plan.ToRemove) != 0 { + t.Fatalf("expected no removal, got %+v", plan.ToRemove) + } + if !plan.NeedsChanges() { + t.Fatal("expected NeedsChanges to be true") + } +} + +func TestBuildPlanPrunesRemovedChapters(t *testing.T) { + dir := setupFolder(t) + tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}}) + client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}} + + m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true} + plan, err := BuildPlan(client, m) + if err != nil { + t.Fatal(err) + } + if len(plan.ToRemove) != 1 || plan.ToRemove[0].Title != "stale" { + t.Fatalf("expected 'stale' to be removed, got %+v", plan.ToRemove) + } +} + +func TestBuildPlanNoPruneKeepsStaleChapters(t *testing.T) { + dir := setupFolder(t) + tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}}) + client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}} + + m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: false} + plan, err := BuildPlan(client, m) + if err != nil { + t.Fatal(err) + } + if len(plan.ToRemove) != 0 { + t.Fatalf("expected no removal, got %+v", plan.ToRemove) + } + want := []string{"01 - First", "02 - Second", "stale"} + if !equalStrings(plan.FinalOrderTitles, want) { + t.Fatalf("expected order %v, got %v", want, plan.FinalOrderTitles) + } +} + +func TestApplyPlanUploadsAndSkipsReorderWhenAlreadyMatching(t *testing.T) { + dir := setupFolder(t) + before := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}}) + after := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "02 - Second"}}) + client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{before, after}} + + m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true} + plan, err := BuildPlan(client, m) + if err != nil { + t.Fatal(err) + } + if err := ApplyPlan(client, m, plan); err != nil { + t.Fatal(err) + } + + if len(client.uploadedTitles) != 1 || client.uploadedTitles[0] != "02 - Second" { + t.Fatalf("expected upload of '02 - Second', got %v", client.uploadedTitles) + } + if len(client.sortedTitles) != 0 { + t.Fatalf("expected no sort call since order already matches, got %v", client.sortedTitles) + } +} + +func TestApplyPlanReordersWhenOrderDiffers(t *testing.T) { + dir := setupFolder(t) + reversed := makeTonie([]tonieapi.Chapter{{ID: "c2", Title: "02 - Second"}, {ID: "c1", Title: "01 - First"}}) + client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{reversed, reversed}} + + m := config.Mapping{Name: "m", HouseholdID: "h1", TonieID: "t1", Folder: dir, Prune: true} + plan, err := BuildPlan(client, m) + if err != nil { + t.Fatal(err) + } + if err := ApplyPlan(client, m, plan); err != nil { + t.Fatal(err) + } + + if len(client.uploadedTitles) != 0 { + t.Fatalf("expected no uploads, got %v", client.uploadedTitles) + } + want := []string{"01 - First", "02 - Second"} + if !equalStrings(client.sortedTitles, want) { + t.Fatalf("expected sorted order %v, got %v", want, client.sortedTitles) + } +} diff --git a/internal/tonieapi/client.go b/internal/tonieapi/client.go new file mode 100644 index 0000000..ba6128b --- /dev/null +++ b/internal/tonieapi/client.go @@ -0,0 +1,250 @@ +// Package tonieapi is a minimal, unofficial Go client for the TonieCloud +// REST API used to manage Kreativ-Tonies. It is not associated with +// Boxine/tonies.de in any way. +package tonieapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "time" +) + +const ( + apiURL = "https://api.tonie.cloud/v2" + tokenURL = "https://login.tonies.com/auth/realms/tonies/protocol/openid-connect/token" + defaultClient = "my-tonies" +) + +// Client is an authenticated TonieCloud API client. +type Client struct { + httpClient *http.Client + token string +} + +// AuthError is returned when login fails. +type AuthError struct { + Cause error +} + +func (e *AuthError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("failed to authenticate with TonieCloud: %v", e.Cause) + } + return "failed to authenticate with TonieCloud" +} + +func (e *AuthError) Unwrap() error { return e.Cause } + +// NewClient logs in with username/password and returns an authenticated client. +func NewClient(username, password string) (*Client, error) { + httpClient := &http.Client{Timeout: 30 * time.Second} + + form := url.Values{} + form.Set("grant_type", "password") + form.Set("client_id", defaultClient) + form.Set("scope", "openid") + form.Set("username", username) + form.Set("password", password) + + resp, err := httpClient.PostForm(tokenURL, form) + if err != nil { + return nil, &AuthError{Cause: err} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &AuthError{Cause: err} + } + if resp.StatusCode != http.StatusOK { + return nil, &AuthError{Cause: fmt.Errorf("login rejected (HTTP %d): %s", resp.StatusCode, string(body))} + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, &AuthError{Cause: err} + } + if tokenResp.AccessToken == "" { + return nil, &AuthError{Cause: fmt.Errorf("no access_token in response")} + } + + return &Client{httpClient: httpClient, token: tokenResp.AccessToken}, nil +} + +func (c *Client) request(method, path string, body any, out any) error { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, apiURL+"/"+path, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("TonieCloud API request %s %s failed (HTTP %d): %s", method, path, resp.StatusCode, string(respBody)) + } + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decoding response of %s %s: %w", method, path, err) + } + } + return nil +} + +// GetHouseholds returns all households of the logged in user. +func (c *Client) GetHouseholds() ([]Household, error) { + var households []Household + if err := c.request(http.MethodGet, "households", nil, &households); err != nil { + return nil, err + } + return households, nil +} + +// GetCreativeTonies returns all Kreativ-Tonies belonging to the given household. +func (c *Client) GetCreativeTonies(householdID string) ([]CreativeTonie, error) { + var tonies []CreativeTonie + path := fmt.Sprintf("households/%s/creativetonies", householdID) + if err := c.request(http.MethodGet, path, nil, &tonies); err != nil { + return nil, err + } + return tonies, nil +} + +// GetAllCreativeTonies returns all Kreativ-Tonies across all households of the logged in user. +func (c *Client) GetAllCreativeTonies() ([]CreativeTonie, error) { + households, err := c.GetHouseholds() + if err != nil { + return nil, err + } + var all []CreativeTonie + for _, h := range households { + tonies, err := c.GetCreativeTonies(h.ID) + if err != nil { + return nil, err + } + all = append(all, tonies...) + } + return all, nil +} + +// GetCreativeTonie fetches a single Kreativ-Tonie by id (searches all households). +func (c *Client) GetCreativeTonie(tonieID string) (*CreativeTonie, error) { + tonies, err := c.GetAllCreativeTonies() + if err != nil { + return nil, err + } + for _, t := range tonies { + if t.ID == tonieID { + return &t, nil + } + } + return nil, fmt.Errorf("creative tonie with id %q not found", tonieID) +} + +// UploadFileToTonie uploads a local audio file and appends it as a new chapter +// with the given title to the given Kreativ-Tonie. +func (c *Client) UploadFileToTonie(tonie CreativeTonie, filePath, title string) error { + var upload uploadRequest + if err := c.request(http.MethodPost, "file", map[string]any{}, &upload); err != nil { + return fmt.Errorf("requesting upload target: %w", err) + } + + if err := c.uploadToS3(upload, filePath); err != nil { + return fmt.Errorf("uploading file to storage: %w", err) + } + + return c.AddChapterToTonie(tonie, upload.FileID, title) +} + +func (c *Client) uploadToS3(upload uploadRequest, filePath string) error { + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + for key, value := range upload.Request.Fields { + if err := writer.WriteField(key, value); err != nil { + return err + } + } + + part, err := writer.CreateFormFile("file", filepath.Base(upload.Request.Fields["key"])) + if err != nil { + return err + } + if _, err := io.Copy(part, f); err != nil { + return err + } + if err := writer.Close(); err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, upload.Request.URL, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("storage upload failed (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// AddChapterToTonie appends a chapter (referencing an already uploaded file id) to a Kreativ-Tonie. +func (c *Client) AddChapterToTonie(tonie CreativeTonie, fileID, title string) error { + path := fmt.Sprintf("households/%s/creativetonies/%s/chapters", tonie.HouseholdID, tonie.ID) + return c.request(http.MethodPost, path, map[string]any{"title": title, "file": fileID}, nil) +} + +// SortChaptersOfTonie replaces the full chapter list of a Kreativ-Tonie with the given +// ordered list. Chapters not included in `chapters` are effectively removed. +func (c *Client) SortChaptersOfTonie(tonie CreativeTonie, chapters []Chapter) error { + path := fmt.Sprintf("households/%s/creativetonies/%s", tonie.HouseholdID, tonie.ID) + return c.request(http.MethodPatch, path, map[string]any{"chapters": chapters}, nil) +} + +// ClearChaptersOfTonie removes all chapters of a Kreativ-Tonie. +func (c *Client) ClearChaptersOfTonie(tonie CreativeTonie) error { + return c.SortChaptersOfTonie(tonie, []Chapter{}) +} diff --git a/internal/tonieapi/models.go b/internal/tonieapi/models.go new file mode 100644 index 0000000..7f50d12 --- /dev/null +++ b/internal/tonieapi/models.go @@ -0,0 +1,49 @@ +package tonieapi + +// User is the currently logged in TonieCloud user. +type User struct { + UUID string `json:"uuid"` + Email string `json:"email"` +} + +// Household is a TonieCloud household (a "family" account). +type Household struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerName string `json:"ownerName"` + Access string `json:"access"` + CanLeave bool `json:"canLeave"` +} + +// Chapter is a single audio chapter on a Creative Tonie. +type Chapter struct { + ID string `json:"id"` + Title string `json:"title"` + File string `json:"file"` + Seconds float64 `json:"seconds"` + Transcoding bool `json:"transcoding"` +} + +// CreativeTonie is a single Kreativ-Tonie figure. +type CreativeTonie struct { + ID string `json:"id"` + HouseholdID string `json:"householdId"` + Name string `json:"name"` + ImageURL string `json:"imageUrl"` + SecondsRemaining float64 `json:"secondsRemaining"` + SecondsPresent float64 `json:"secondsPresent"` + ChaptersRemaining int `json:"chaptersRemaining"` + ChaptersPresent int `json:"chaptersPresent"` + Transcoding bool `json:"transcoding"` + LastUpdate *string `json:"lastUpdate"` + Chapters []Chapter `json:"chapters"` +} + +// uploadRequest describes the pre-signed S3 upload target returned by POST /file. +type uploadRequest struct { + Request struct { + URL string `json:"url"` + Fields map[string]string `json:"fields"` + } `json:"request"` + FileID string `json:"fileId"` +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f75aa6c..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[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", -] diff --git a/src/toni_sync/__init__.py b/src/toni_sync/__init__.py deleted file mode 100644 index 43c2bed..0000000 --- a/src/toni_sync/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""toni-sync: Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies.""" - -__version__ = "0.1.0" diff --git a/src/toni_sync/cli.py b/src/toni_sync/cli.py deleted file mode 100644 index 005cfdd..0000000 --- a/src/toni_sync/cli.py +++ /dev/null @@ -1,201 +0,0 @@ -"""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() diff --git a/src/toni_sync/client.py b/src/toni_sync/client.py deleted file mode 100644 index c39ffdb..0000000 --- a/src/toni_sync/client.py +++ /dev/null @@ -1,47 +0,0 @@ -"""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) diff --git a/src/toni_sync/config.py b/src/toni_sync/config.py deleted file mode 100644 index 339c68e..0000000 --- a/src/toni_sync/config.py +++ /dev/null @@ -1,72 +0,0 @@ -"""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) diff --git a/src/toni_sync/sync.py b/src/toni_sync/sync.py deleted file mode 100644 index 0b972b1..0000000 --- a/src/toni_sync/sync.py +++ /dev/null @@ -1,91 +0,0 @@ -"""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) diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 43ab315..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,48 +0,0 @@ -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 == [] diff --git a/tests/test_sync.py b/tests/test_sync.py deleted file mode 100644 index 4723d5d..0000000 --- a/tests/test_sync.py +++ /dev/null @@ -1,127 +0,0 @@ -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"]