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:
2026-08-14 06:52:11 +02:00
co-authored by Copilot
parent 4860edf886
commit d003c32433
23 changed files with 1278 additions and 631 deletions
+102
View File
@@ -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
}
+74
View File
@@ -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)
}
}
+215
View File
@@ -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
}
+177
View File
@@ -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)
}
}
+250
View File
@@ -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{})
}
+49
View File
@@ -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"`
}