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