Replace config file with folder-based mapping

Mappings between local playlist folders and Kreativ-Tonies are now
expressed purely through the filesystem instead of a YAML config:

  <root>/<household_id>/<freier_name> [id=<tonie_id>]/*.mp3

- internal/library: discovers tonie folders from this structure
  (Discover/Filter), and scaffolds it automatically from the
  TonieCloud account (Scaffold), leaving already-existing folders
  (matched by tonie id) untouched
- internal/syncer: decoupled from the removed config package via a
  plain Target{TonieID, Folder, Prune} struct
- cmd/toni-sync: new `init --root PATH` command to scaffold the
  library; `sync [FILTER] --root PATH` now discovers targets from the
  folder tree instead of a config file; removed the `config` command
  group entirely
- Unit tests for folder discovery, filtering, and scaffolding

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-14 12:02:54 +02:00
co-authored by Copilot
parent d003c32433
commit ae46e8f5c0
15 changed files with 580 additions and 395 deletions
-102
View File
@@ -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
}
-74
View File
@@ -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)
}
}
+109
View File
@@ -0,0 +1,109 @@
// Package library discovers Kreativ-Tonie mappings from a folder structure,
// instead of a separate config file:
//
// <root>/<household_id>/<freier_name> [id=<tonie_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<name>.+) \[id=(?P<id>[^\]]+)\]$`)
// 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 "<name> [id=<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: "<name> [id=<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
}
+91
View File
@@ -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)
}
}
+98
View File
@@ -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 "<root>/<household_id>/<name> [id=<tonie_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
}
+80
View File
@@ -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)
}
}
+15 -7
View File
@@ -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)
}
+5 -6
View File
@@ -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)