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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user