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