diff --git a/README.md b/README.md index 77f8148..94af214 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,33 @@ 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 > Nutzungsbedingungen von Deezer und ggf. gegen Urheberrecht. Lege deine -> bereits legal exportierten Audiodateien einfach in die konfigurierten +> bereits legal exportierten Audiodateien einfach in die passenden > lokalen Ordner - toni-sync kümmert sich nur um den Abgleich mit der > TonieCloud. +## Keine Config-Datei - die Ordnerstruktur *ist* die Konfiguration + +Statt einer separaten Config-Datei wird das Mapping Playlist ↔ Kreativ-Tonie +direkt über die Ordnerstruktur abgebildet: + +``` +/ + / + [id=]/ + 01 - Track.mp3 + 02 - Track.mp3 +``` + +- ``: die Household-ID deines TonieCloud-Accounts +- ` [id=]`: frei wählbarer Name + Tonie-ID in eckigen + Klammern - der Name dient nur der Lesbarkeit, für das Mapping zählt allein + die ID +- Die alphabetische Dateireihenfolge innerhalb eines Tonie-Ordners bestimmt + die Kapitelreihenfolge auf dem Tonie + ## Installation -Benötigt wird nur ein Go-Toolchain (>= 1.21) zum Bauen - danach ist das +Benötigt wird nur eine Go-Toolchain (>= 1.21) zum Bauen - danach ist das Ergebnis eine einzelne Binary ohne weitere Abhängigkeiten: ```bash @@ -40,66 +60,72 @@ export TONI_SYNC_USERNAME="you@example.com" export TONI_SYNC_PASSWORD="********" ``` -Alternativ: `--username`/`--password` Flags bei `tonies list` und `sync`. +Alternativ: `--username`/`--password` Flags bei `tonies list`, `init` und `sync`. ## Nutzung -### 1. Household- und Tonie-IDs herausfinden +### 1. Library-Ordner automatisch anlegen + +```bash +toni-sync init --root ~/Musik/tonies +``` + +Legt für jede Household und jeden Kreativ-Tonie deines Accounts den +passenden Ordner an, z. B.: + +``` +[created] /home/you/Musik/tonies/abcd-1234/Peppa Wutz [id=ef01-5678] +[created] /home/you/Musik/tonies/abcd-1234/Gute-Nacht-Geschichten [id=9876-4321] +Library ready at /home/you/Musik/tonies +``` + +Bereits vorhandene Ordner (anhand der Tonie-ID erkannt, auch bei geändertem +Namen) werden nicht angefasst - deine Audiodateien bleiben unberührt. + +Alternativ manuell: einfach die Ordner nach obigem Schema selbst anlegen. + +### 2. Root-Ordner festlegen + +Der Library-Root wird wie folgt bestimmt (erste zutreffende Option): + +1. `--root PATH` Flag +2. Umgebungsvariable `TONI_SYNC_ROOT` +3. aktuelles Arbeitsverzeichnis + +### 3. Audiodateien einsortieren & synchronisieren + +Lege deine (legal exportierten) Audiodateien in den passenden Tonie-Ordner, +z. B. `01 - Track.mp3`, `02 - Track.mp3`, ... + +```bash +# alle gefundenen Tonies synchronisieren +toni-sync sync --root ~/Musik/tonies + +# nur Tonies syncen, deren Household-ID/Tonie-ID/Name den Filter enthalten +toni-sync sync peppa --root ~/Musik/tonies + +# nur anzeigen, was sich ändern würde +toni-sync sync --root ~/Musik/tonies --dry-run +``` + +`sync` lädt neue Dateien hoch, entfernt Kapitel, die lokal nicht mehr +existieren (abschaltbar via `--no-prune`), und sortiert die Kapitel passend +zur lokalen Dateireihenfolge. + +### Household-/Tonie-IDs nachschlagen + +Falls du die Struktur lieber manuell pflegen willst: ```bash toni-sync tonies list ``` -Beispielausgabe: - ``` Household: Familie Müller [id=abcd-1234] - Peppa Wutz [id=ef01-5678] (12 chapters, 3600s) - Gute-Nacht-Geschichten [id=9876-4321] (0 chapters, 0s) ``` -### 2. Playlist <-> Toni Mapping konfigurieren - -```bash -toni-sync config add \ - --name peppa-wutz \ - --household-id abcd-1234 \ - --tonie-id ef01-5678 \ - --folder ~/Musik/deezer-export/peppa-wutz \ - --playlist-ref "https://www.deezer.com/playlist/XXXXXXXXX" -``` - -Weitere Kommandos: - -```bash -toni-sync config list -toni-sync config remove peppa-wutz -``` - -Konfiguration wird standardmäßig in `~/.config/toni-sync/config.yaml` -gespeichert (überschreibbar via `--config-path` oder `TONI_SYNC_CONFIG`). - -### 3. Synchronisieren - -Lege deine (legal exportierten) Audiodateien in den konfigurierten Ordner, -z. B. `01 - Track.mp3`, `02 - Track.mp3`, ... - die alphabetische -Dateireihenfolge bestimmt die Kapitelreihenfolge auf dem Tonie. - -```bash -# einzelnes Mapping -toni-sync sync peppa-wutz - -# alle Mappings -toni-sync sync --all - -# nur anzeigen, was sich ändern würde -toni-sync sync --all --dry-run -``` - -`sync` lädt neue Dateien hoch, entfernt Kapitel, die lokal nicht mehr -existieren (abschaltbar via `--no-prune` bei `config add`), und sortiert -die Kapitel passend zur lokalen Dateireihenfolge. - ## Unterstützte Audioformate `.mp3`, `.m4a`, `.aac`, `.ogg`, `.flac`, `.wav` @@ -107,10 +133,10 @@ die Kapitel passend zur lokalen Dateireihenfolge. ## 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 +cmd/toni-sync/ CLI-Einstiegspunkt (Cobra-Kommandos: tonies, init, sync) +internal/tonieapi/ Eigener, minimaler TonieCloud-API-Client +internal/library/ Erkennung & Scaffolding der Ordnerstruktur (Household/Tonie) +internal/syncer/ Diff-/Apply-Logik zwischen lokalem Ordner und Tonie ``` ## Entwicklung / Tests diff --git a/cmd/toni-sync/config.go b/cmd/toni-sync/config.go deleted file mode 100644 index fa8e11a..0000000 --- a/cmd/toni-sync/config.go +++ /dev/null @@ -1,117 +0,0 @@ -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/init.go b/cmd/toni-sync/init.go new file mode 100644 index 0000000..2ade96e --- /dev/null +++ b/cmd/toni-sync/init.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/arnef/toni-sync/internal/library" +) + +func newInitCmd() *cobra.Command { + var username, password string + + cmd := &cobra.Command{ + Use: "init", + Short: "Scaffold the library folder structure from your TonieCloud account.", + Long: "Creates '// [id=]' folders\n" + + "for every household and Kreativ-Tonie on your account. Folders that\n" + + "already exist (matched by tonie id) are left untouched so your local\n" + + "audio files are never affected.", + RunE: func(cmd *cobra.Command, args []string) error { + root, err := resolveRoot() + if err != nil { + return err + } + + client, err := resolveClient(username, password) + if err != nil { + return err + } + + results, err := library.Scaffold(client, root) + if err != nil { + return err + } + + for _, r := range results { + status := "created" + if !r.Created { + status = "exists" + } + fmt.Printf("[%s] %s\n", status, r.Folder) + } + fmt.Printf("Library ready at %s\n", root) + 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/cmd/toni-sync/main.go b/cmd/toni-sync/main.go index 46f10aa..7009968 100644 --- a/cmd/toni-sync/main.go +++ b/cmd/toni-sync/main.go @@ -7,17 +7,35 @@ import ( "github.com/spf13/cobra" ) -var configPath string +var libraryRoot string + +// resolveRoot returns the library root folder, honoring --root, then +// TONI_SYNC_ROOT, then falling back to the current working directory. +func resolveRoot() (string, error) { + if libraryRoot != "" { + return libraryRoot, nil + } + if env := os.Getenv("TONI_SYNC_ROOT"); env != "" { + return env, nil + } + return os.Getwd() +} func main() { rootCmd := &cobra.Command{ Use: "toni-sync", Short: "Sync local audio folders (e.g. exported Deezer playlists) to Kreativ-Tonies.", + Long: "toni-sync manages Kreativ-Tonies via a plain folder structure:\n\n" + + " // [id=]/*.mp3\n\n" + + "There is no separate config file - the folder structure itself is the\n" + + "configuration. Use `toni-sync init` to scaffold it automatically from\n" + + "your TonieCloud account, then place your audio files in the resulting\n" + + "tonie folders and run `toni-sync sync`.", } - rootCmd.PersistentFlags().StringVar(&configPath, "config-path", "", "Path to the toni-sync config file (default: ~/.config/toni-sync/config.yaml)") + rootCmd.PersistentFlags().StringVar(&libraryRoot, "root", "", "Library root folder (default: env TONI_SYNC_ROOT or current directory)") rootCmd.AddCommand(newToniesCmd()) - rootCmd.AddCommand(newConfigCmd()) + rootCmd.AddCommand(newInitCmd()) rootCmd.AddCommand(newSyncCmd()) if err := rootCmd.Execute(); err != nil { diff --git a/cmd/toni-sync/sync.go b/cmd/toni-sync/sync.go index 9d69275..6f73f58 100644 --- a/cmd/toni-sync/sync.go +++ b/cmd/toni-sync/sync.go @@ -5,44 +5,44 @@ import ( "github.com/spf13/cobra" - "github.com/arnef/toni-sync/internal/config" + "github.com/arnef/toni-sync/internal/library" "github.com/arnef/toni-sync/internal/syncer" "github.com/arnef/toni-sync/internal/tonieapi" ) func newSyncCmd() *cobra.Command { - var syncAll, dryRun bool + var dryRun, noPrune 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.", + Use: "sync [FILTER]", + Short: "Sync the library folder structure to the matching Kreativ-Tonies.", + Long: "Discovers all '// [id=]'\n" + + "folders and syncs each one to its Kreativ-Tonie: uploads new files,\n" + + "removes chapters no longer present locally (unless --no-prune is set),\n" + + "and reorders chapters to match the local folder's file order.\n\n" + + "FILTER optionally restricts syncing to tonies whose household id, tonie\n" + + "id, or name contains the given text (case-insensitive). Without a\n" + + "filter, all discovered tonies are synced.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - var name string + var filter string if len(args) == 1 { - name = args[0] - } - if !syncAll && name == "" { - return fmt.Errorf("specify a mapping NAME or use --all") + filter = args[0] } - cfg, _, err := loadConfig() + root, err := resolveRoot() 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} + tonies, err := library.Discover(root) + if err != nil { + return err } - if len(targets) == 0 { - return fmt.Errorf("no mapping named '%s' found", name) + tonies = library.Filter(tonies, filter) + if len(tonies) == 0 { + return fmt.Errorf("no matching tonie folders found under %s", root) } client, err := resolveClient(username, password) @@ -50,34 +50,35 @@ func newSyncCmd() *cobra.Command { return err } - for _, m := range targets { - if err := syncOne(client, m, dryRun); err != nil { - return fmt.Errorf("syncing '%s': %w", m.Name, err) + for _, t := range tonies { + if err := syncOne(client, t, !noPrune, dryRun); err != nil { + return fmt.Errorf("syncing '%s' [id=%s]: %w", t.Name, t.TonieID, 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().BoolVar(&noPrune, "no-prune", false, "Never remove chapters that are missing locally.") 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) +func syncOne(client *tonieapi.Client, t library.Tonie, prune, dryRun bool) error { + fmt.Printf("== %s [id=%s] ==\n", t.Name, t.TonieID) - plan, err := syncer.BuildPlan(client, m) + target := syncer.Target{TonieID: t.TonieID, Folder: t.Folder, Prune: prune} + plan, err := syncer.BuildPlan(client, target) 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) + for _, track := range plan.ToUpload { + fmt.Printf(" + %s\n", track.Title) } } if len(plan.ToRemove) > 0 { @@ -96,7 +97,7 @@ func syncOne(client *tonieapi.Client, m config.Mapping, dryRun bool) error { return nil } - if err := syncer.ApplyPlan(client, m, plan); err != nil { + if err := syncer.ApplyPlan(client, target, plan); err != nil { return err } fmt.Println(" Done.") diff --git a/go.mod b/go.mod index c5d105c..0523f2a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ 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 ( diff --git a/go.sum b/go.sum index 37f8f68..d73aa50 100644 --- a/go.sum +++ b/go.sum @@ -11,7 +11,4 @@ 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 deleted file mode 100644 index a080491..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,102 +0,0 @@ -// 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 deleted file mode 100644 index 568bf8a..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,74 +0,0 @@ -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/library/library.go b/internal/library/library.go new file mode 100644 index 0000000..e4c506f --- /dev/null +++ b/internal/library/library.go @@ -0,0 +1,109 @@ +// Package library discovers Kreativ-Tonie mappings from a folder structure, +// instead of a separate config file: +// +// // [id=]/*.mp3 +// +// The household id is the top-level folder name, and each tonie folder +// name encodes both a free-form display name and the tonie id in brackets. +// This keeps the "configuration" fully visible and editable as plain +// folders on disk. +package library + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// tonieDirPattern matches folder names like "Peppa Wutz [id=ef01-5678]". +var tonieDirPattern = regexp.MustCompile(`^(?P.+) \[id=(?P[^\]]+)\]$`) + +// Tonie is a single discovered mapping between a local folder and a Kreativ-Tonie. +type Tonie struct { + HouseholdID string // taken from the top-level folder name + TonieID string // parsed from the "[id=...]" suffix of the folder name + Name string // free-form part of the folder name (before " [id=...]") + Folder string // absolute path to the folder containing the audio files +} + +// ParseTonieDir parses a single tonie folder name into name and id. +// Returns ok=false if the name doesn't match the expected " [id=]" pattern. +func ParseTonieDir(dirName string) (name, id string, ok bool) { + m := tonieDirPattern.FindStringSubmatch(dirName) + if m == nil { + return "", "", false + } + return m[1], m[2], true +} + +// DirName builds the folder name for a tonie: " [id=]". +func DirName(name, id string) string { + return fmt.Sprintf("%s [id=%s]", sanitizeName(name), id) +} + +// sanitizeName strips path separators and other characters that are unsafe in folder names. +func sanitizeName(name string) string { + replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-") + return strings.TrimSpace(replacer.Replace(name)) +} + +// Discover walks root and returns all tonie mappings found in it. +// Non-matching entries (files, folders without the "[id=...]" suffix) are skipped. +func Discover(root string) ([]Tonie, error) { + householdEntries, err := os.ReadDir(root) + if err != nil { + return nil, fmt.Errorf("reading library root %s: %w", root, err) + } + + var tonies []Tonie + for _, hEntry := range householdEntries { + if !hEntry.IsDir() { + continue + } + householdID := hEntry.Name() + householdPath := filepath.Join(root, householdID) + + tonieEntries, err := os.ReadDir(householdPath) + if err != nil { + return nil, fmt.Errorf("reading household folder %s: %w", householdPath, err) + } + + for _, tEntry := range tonieEntries { + if !tEntry.IsDir() { + continue + } + name, tonieID, ok := ParseTonieDir(tEntry.Name()) + if !ok { + continue + } + tonies = append(tonies, Tonie{ + HouseholdID: householdID, + TonieID: tonieID, + Name: name, + Folder: filepath.Join(householdPath, tEntry.Name()), + }) + } + } + return tonies, nil +} + +// Filter returns only the tonies matching the given filter string, matched +// case-insensitively as a substring against household id, tonie id, and name. +// An empty filter returns all tonies. +func Filter(tonies []Tonie, filter string) []Tonie { + if filter == "" { + return tonies + } + filter = strings.ToLower(filter) + var matched []Tonie + for _, t := range tonies { + if strings.Contains(strings.ToLower(t.HouseholdID), filter) || + strings.Contains(strings.ToLower(t.TonieID), filter) || + strings.Contains(strings.ToLower(t.Name), filter) { + matched = append(matched, t) + } + } + return matched +} diff --git a/internal/library/library_test.go b/internal/library/library_test.go new file mode 100644 index 0000000..f98a513 --- /dev/null +++ b/internal/library/library_test.go @@ -0,0 +1,91 @@ +package library + +import ( + "os" + "path/filepath" + "testing" +) + +func mkTonieDir(t *testing.T, householdPath, dirName string) string { + t.Helper() + p := filepath.Join(householdPath, dirName) + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestParseTonieDir(t *testing.T) { + name, id, ok := ParseTonieDir("Peppa Wutz [id=ef01-5678]") + if !ok || name != "Peppa Wutz" || id != "ef01-5678" { + t.Fatalf("unexpected parse result: name=%q id=%q ok=%v", name, id, ok) + } +} + +func TestParseTonieDirNoMatch(t *testing.T) { + if _, _, ok := ParseTonieDir("just a folder"); ok { + t.Fatal("expected no match for folder without [id=...] suffix") + } +} + +func TestDirName(t *testing.T) { + if got := DirName("Peppa/Wutz", "abc"); got != "Peppa-Wutz [id=abc]" { + t.Fatalf("unexpected dir name: %q", got) + } +} + +func TestDiscover(t *testing.T) { + root := t.TempDir() + household := filepath.Join(root, "household-1") + if err := os.MkdirAll(household, 0o755); err != nil { + t.Fatal(err) + } + mkTonieDir(t, household, "Peppa Wutz [id=tonie-1]") + mkTonieDir(t, household, "not-a-tonie-folder") + + // A second household with one tonie. + household2 := filepath.Join(root, "household-2") + mkTonieDir(t, household2, "Gute Nacht [id=tonie-2]") + + tonies, err := Discover(root) + if err != nil { + t.Fatal(err) + } + if len(tonies) != 2 { + t.Fatalf("expected 2 tonies, got %d: %+v", len(tonies), tonies) + } + + byID := map[string]Tonie{} + for _, tn := range tonies { + byID[tn.TonieID] = tn + } + + got, ok := byID["tonie-1"] + if !ok || got.HouseholdID != "household-1" || got.Name != "Peppa Wutz" { + t.Fatalf("unexpected tonie-1 entry: %+v", got) + } + got2, ok := byID["tonie-2"] + if !ok || got2.HouseholdID != "household-2" || got2.Name != "Gute Nacht" { + t.Fatalf("unexpected tonie-2 entry: %+v", got2) + } +} + +func TestFilter(t *testing.T) { + tonies := []Tonie{ + {HouseholdID: "h1", TonieID: "t1", Name: "Peppa Wutz"}, + {HouseholdID: "h2", TonieID: "t2", Name: "Gute Nacht"}, + } + + if got := Filter(tonies, ""); len(got) != 2 { + t.Fatalf("empty filter should return all, got %d", len(got)) + } + if got := Filter(tonies, "peppa"); len(got) != 1 || got[0].TonieID != "t1" { + t.Fatalf("expected 1 match for 'peppa', got %+v", got) + } + if got := Filter(tonies, "h2"); len(got) != 1 || got[0].TonieID != "t2" { + t.Fatalf("expected 1 match for 'h2', got %+v", got) + } + if got := Filter(tonies, "nomatch"); len(got) != 0 { + t.Fatalf("expected no matches, got %+v", got) + } +} diff --git a/internal/library/scaffold.go b/internal/library/scaffold.go new file mode 100644 index 0000000..366fe59 --- /dev/null +++ b/internal/library/scaffold.go @@ -0,0 +1,98 @@ +package library + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/arnef/toni-sync/internal/tonieapi" +) + +// ScaffoldClient is the subset of tonieapi.Client needed to scaffold a library folder tree. +type ScaffoldClient interface { + GetHouseholds() ([]tonieapi.Household, error) + GetCreativeTonies(householdID string) ([]tonieapi.CreativeTonie, error) +} + +// ScaffoldResult reports what Scaffold did for a single tonie. +type ScaffoldResult struct { + HouseholdID string + TonieID string + Name string + Folder string + Created bool // false if a matching folder already existed and was left untouched +} + +// Scaffold creates the "// [id=]" folder +// structure for every household and Kreativ-Tonie on the account. Existing +// tonie folders (matched by id, regardless of the current display name) are +// left untouched so local audio files are never affected. +func Scaffold(client ScaffoldClient, root string) ([]ScaffoldResult, error) { + households, err := client.GetHouseholds() + if err != nil { + return nil, err + } + + var results []ScaffoldResult + for _, h := range households { + householdPath := filepath.Join(root, h.ID) + if err := os.MkdirAll(householdPath, 0o755); err != nil { + return nil, fmt.Errorf("creating household folder %s: %w", householdPath, err) + } + + existingByID, err := existingTonieFoldersByID(householdPath) + if err != nil { + return nil, err + } + + tonies, err := client.GetCreativeTonies(h.ID) + if err != nil { + return nil, err + } + + for _, t := range tonies { + if existingDir, ok := existingByID[t.ID]; ok { + results = append(results, ScaffoldResult{ + HouseholdID: h.ID, + TonieID: t.ID, + Name: t.Name, + Folder: filepath.Join(householdPath, existingDir), + Created: false, + }) + continue + } + + dirName := DirName(t.Name, t.ID) + tonieFolder := filepath.Join(householdPath, dirName) + if err := os.MkdirAll(tonieFolder, 0o755); err != nil { + return nil, fmt.Errorf("creating tonie folder %s: %w", tonieFolder, err) + } + results = append(results, ScaffoldResult{ + HouseholdID: h.ID, + TonieID: t.ID, + Name: t.Name, + Folder: tonieFolder, + Created: true, + }) + } + } + return results, nil +} + +// existingTonieFoldersByID maps tonie id -> existing folder name within a household folder. +func existingTonieFoldersByID(householdPath string) (map[string]string, error) { + entries, err := os.ReadDir(householdPath) + if err != nil { + return nil, fmt.Errorf("reading household folder %s: %w", householdPath, err) + } + byID := make(map[string]string) + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, id, ok := ParseTonieDir(e.Name()); ok { + byID[id] = e.Name() + } + } + return byID, nil +} diff --git a/internal/library/scaffold_test.go b/internal/library/scaffold_test.go new file mode 100644 index 0000000..e41ca60 --- /dev/null +++ b/internal/library/scaffold_test.go @@ -0,0 +1,80 @@ +package library + +import ( + "os" + "path/filepath" + "testing" + + "github.com/arnef/toni-sync/internal/tonieapi" +) + +type fakeScaffoldClient struct { + households []tonieapi.Household + toniesByHouse map[string][]tonieapi.CreativeTonie +} + +func (f *fakeScaffoldClient) GetHouseholds() ([]tonieapi.Household, error) { + return f.households, nil +} + +func (f *fakeScaffoldClient) GetCreativeTonies(householdID string) ([]tonieapi.CreativeTonie, error) { + return f.toniesByHouse[householdID], nil +} + +func TestScaffoldCreatesFolders(t *testing.T) { + root := t.TempDir() + client := &fakeScaffoldClient{ + households: []tonieapi.Household{{ID: "h1", Name: "Family"}}, + toniesByHouse: map[string][]tonieapi.CreativeTonie{ + "h1": {{ID: "t1", HouseholdID: "h1", Name: "Peppa Wutz"}}, + }, + } + + results, err := Scaffold(client, root) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || !results[0].Created { + t.Fatalf("expected 1 created result, got %+v", results) + } + + expected := filepath.Join(root, "h1", "Peppa Wutz [id=t1]") + if info, err := os.Stat(expected); err != nil || !info.IsDir() { + t.Fatalf("expected folder %s to exist: %v", expected, err) + } +} + +func TestScaffoldLeavesExistingFolderUntouched(t *testing.T) { + root := t.TempDir() + // Simulate an existing folder that was renamed locally but keeps the id suffix. + existing := filepath.Join(root, "h1", "My Custom Name [id=t1]") + if err := os.MkdirAll(existing, 0o755); err != nil { + t.Fatal(err) + } + // Put a marker file inside to make sure Scaffold doesn't touch it. + marker := filepath.Join(existing, "song.mp3") + if err := os.WriteFile(marker, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + + client := &fakeScaffoldClient{ + households: []tonieapi.Household{{ID: "h1", Name: "Family"}}, + toniesByHouse: map[string][]tonieapi.CreativeTonie{ + "h1": {{ID: "t1", HouseholdID: "h1", Name: "Peppa Wutz (renamed in cloud)"}}, + }, + } + + results, err := Scaffold(client, root) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Created { + t.Fatalf("expected existing folder to be reported as not created, got %+v", results) + } + if results[0].Folder != existing { + t.Fatalf("expected folder %s, got %s", existing, results[0].Folder) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("expected local file to remain untouched: %v", err) + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 8db18b0..b046ebe 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -9,7 +9,6 @@ import ( "sort" "strings" - "github.com/arnef/toni-sync/internal/config" "github.com/arnef/toni-sync/internal/tonieapi" ) @@ -21,6 +20,15 @@ type TonieClient interface { SortChaptersOfTonie(tonie tonieapi.CreativeTonie, chapters []tonieapi.Chapter) error } +// Target describes what to sync: a local folder and the Kreativ-Tonie it maps to. +// Mappings come from the on-disk library folder structure (see internal/library), +// not from a separate config file. +type Target struct { + TonieID string + Folder string + Prune bool // remove chapters that no longer have a matching local file +} + const maxTitleLength = 100 var audioExtensions = map[string]bool{ @@ -110,13 +118,13 @@ func (p *Plan) NeedsChanges() bool { } // 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) +func BuildPlan(client TonieClient, target Target) (*Plan, error) { + tonie, err := client.GetCreativeTonie(target.TonieID) if err != nil { return nil, err } - tracks, err := ListLocalTracks(m.Folder) + tracks, err := ListLocalTracks(target.Folder) if err != nil { return nil, err } @@ -142,7 +150,7 @@ func BuildPlan(client TonieClient, m config.Mapping) (*Plan, error) { var toRemove []tonieapi.Chapter finalOrder := append([]string{}, orderedTitles...) - if m.Prune { + if target.Prune { for _, c := range tonie.Chapters { if !localTitles[c.Title] { toRemove = append(toRemove, c) @@ -166,14 +174,14 @@ func BuildPlan(client TonieClient, m config.Mapping) (*Plan, error) { } // ApplyPlan uploads missing tracks, then reorders/prunes chapters to match the local folder. -func ApplyPlan(client TonieClient, m config.Mapping, plan *Plan) error { +func ApplyPlan(client TonieClient, target Target, 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) + refreshed, err := client.GetCreativeTonie(target.TonieID) if err != nil { return fmt.Errorf("refetching tonie after upload: %w", err) } diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index d8cd6ba..6f975e0 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "testing" - "github.com/arnef/toni-sync/internal/config" "github.com/arnef/toni-sync/internal/tonieapi" ) @@ -79,7 +78,7 @@ func TestBuildPlanDetectsUploadNoRemoval(t *testing.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} + m := Target{TonieID: "t1", Folder: dir, Prune: true} plan, err := BuildPlan(client, m) if err != nil { t.Fatal(err) @@ -101,7 +100,7 @@ func TestBuildPlanPrunesRemovedChapters(t *testing.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} + m := Target{TonieID: "t1", Folder: dir, Prune: true} plan, err := BuildPlan(client, m) if err != nil { t.Fatal(err) @@ -116,7 +115,7 @@ func TestBuildPlanNoPruneKeepsStaleChapters(t *testing.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} + m := Target{TonieID: "t1", Folder: dir, Prune: false} plan, err := BuildPlan(client, m) if err != nil { t.Fatal(err) @@ -136,7 +135,7 @@ func TestApplyPlanUploadsAndSkipsReorderWhenAlreadyMatching(t *testing.T) { 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} + m := Target{TonieID: "t1", Folder: dir, Prune: true} plan, err := BuildPlan(client, m) if err != nil { t.Fatal(err) @@ -158,7 +157,7 @@ func TestApplyPlanReordersWhenOrderDiffers(t *testing.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} + m := Target{TonieID: "t1", Folder: dir, Prune: true} plan, err := BuildPlan(client, m) if err != nil { t.Fatal(err)