- 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>
251 lines
7.0 KiB
Go
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{})
|
|
}
|