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