Files
toni-sync/internal/tonieapi/client.go
T
arnefandCopilot d003c32433 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>
2026-08-14 06:52:11 +02:00

251 lines
7.0 KiB
Go

// 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{})
}