mirror of
https://github.com/sstent/garminsync-go.git
synced 2025-12-06 08:01:52 +00:00
moved to python for garmin api and golang for everything else
This commit is contained in:
@@ -7,19 +7,35 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8888:8888"
|
- "8888:8888"
|
||||||
env_file:
|
env_file:
|
||||||
- .env # Use the root .env file
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./internal/web/templates:/app/internal/web/templates
|
- ./internal/web/templates:/app/internal/web/templates
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- garmin-api
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 30s # Increased timeout for startup
|
timeout: 30s
|
||||||
retries: 3
|
retries: 3
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
|
garmin-api:
|
||||||
|
build: ./garmin-api-wrapper
|
||||||
|
container_name: garmin-api
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|||||||
25
garmin-api-wrapper/Dockerfile
Normal file
25
garmin-api-wrapper/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Builder stage
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependencies from builder stage
|
||||||
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
# Add user for security
|
||||||
|
RUN groupadd -r appuser && \
|
||||||
|
useradd -r -g appuser appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
CMD ["python", "app.py"]
|
||||||
86
garmin-api-wrapper/app.py
Normal file
86
garmin-api-wrapper/app.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from garminconnect import Garmin
|
||||||
|
import logging
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
GARMIN_EMAIL = os.getenv("GARMIN_EMAIL")
|
||||||
|
GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD")
|
||||||
|
|
||||||
|
def init_api():
|
||||||
|
"""Initializes the Garmin API client."""
|
||||||
|
try:
|
||||||
|
api = Garmin(GARMIN_EMAIL, GARMIN_PASSWORD)
|
||||||
|
api.login()
|
||||||
|
logger.info("Successfully authenticated with Garmin API")
|
||||||
|
return api
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initializing Garmin API: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.route('/stats', methods=['GET'])
|
||||||
|
def get_stats():
|
||||||
|
"""Endpoint to get user stats."""
|
||||||
|
stats_date = request.args.get('date')
|
||||||
|
if not stats_date:
|
||||||
|
return jsonify({"error": "A 'date' query parameter is required in YYYY-MM-DD format."}), 400
|
||||||
|
|
||||||
|
api = init_api()
|
||||||
|
if not api:
|
||||||
|
return jsonify({"error": "Failed to connect to Garmin API"}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching stats for date: {stats_date}")
|
||||||
|
user_stats = api.get_stats(stats_date)
|
||||||
|
return jsonify(user_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching stats: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/activities', methods=['GET'])
|
||||||
|
def get_activities():
|
||||||
|
"""Endpoint to get activities list."""
|
||||||
|
start = request.args.get('start', default=0, type=int)
|
||||||
|
limit = request.args.get('limit', default=10, type=int)
|
||||||
|
|
||||||
|
api = init_api()
|
||||||
|
if not api:
|
||||||
|
return jsonify({"error": "Failed to connect to Garmin API"}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching activities from {start} with limit {limit}")
|
||||||
|
activities = api.get_activities(start, limit)
|
||||||
|
return jsonify(activities)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching activities: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/activities/<activity_id>', methods=['GET'])
|
||||||
|
def get_activity_details(activity_id):
|
||||||
|
"""Endpoint to get activity details."""
|
||||||
|
api = init_api()
|
||||||
|
if not api:
|
||||||
|
return jsonify({"error": "Failed to connect to Garmin API"}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching activity details for {activity_id}")
|
||||||
|
activity = api.get_activity(activity_id)
|
||||||
|
return jsonify(activity)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching activity details: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return jsonify({"status": "healthy", "service": "garmin-api"})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=8081)
|
||||||
3
garmin-api-wrapper/requirements.txt
Normal file
3
garmin-api-wrapper/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
garminconnect==0.2.28
|
||||||
|
garth
|
||||||
|
Flask
|
||||||
69
garmin-api-wrapper/test_api.py
Normal file
69
garmin-api-wrapper/test_api.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from garminconnect import Garmin
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
print("=== Starting Garmin API Tests ===")
|
||||||
|
|
||||||
|
# Load credentials from environment
|
||||||
|
email = os.getenv("GARMIN_EMAIL")
|
||||||
|
password = os.getenv("GARMIN_PASSWORD")
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
logger.error("GARMIN_EMAIL or GARMIN_PASSWORD environment variables not set")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Test Authentication
|
||||||
|
logger.info("Testing authentication...")
|
||||||
|
api = Garmin(email, password)
|
||||||
|
api.login()
|
||||||
|
logger.info("✅ Authentication successful")
|
||||||
|
|
||||||
|
# 2. Test Activity Listing
|
||||||
|
logger.info("Testing activity listing...")
|
||||||
|
activities = api.get_activities(0, 1) # Get 1 most recent activity
|
||||||
|
if not activities:
|
||||||
|
logger.error("❌ No activities found")
|
||||||
|
else:
|
||||||
|
logger.info(f"✅ Found {len(activities)} activities")
|
||||||
|
print("Sample activity:")
|
||||||
|
print(json.dumps(activities[0], indent=2)[:1000]) # Print first 1000 chars
|
||||||
|
|
||||||
|
# 3. Test Activity Download (if we got any activities)
|
||||||
|
if activities:
|
||||||
|
logger.info("Testing activity download...")
|
||||||
|
activity_id = activities[0]["activityId"]
|
||||||
|
details = api.get_activity(activity_id)
|
||||||
|
if details:
|
||||||
|
logger.info(f"✅ Activity {activity_id} details retrieved")
|
||||||
|
print("Sample details:")
|
||||||
|
print(json.dumps(details, indent=2)[:1000]) # Print first 1000 chars
|
||||||
|
else:
|
||||||
|
logger.error("❌ Failed to get activity details")
|
||||||
|
|
||||||
|
# 4. Test Stats Retrieval
|
||||||
|
logger.info("Testing stats retrieval...")
|
||||||
|
stats = api.get_stats(datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
if stats:
|
||||||
|
logger.info("✅ Stats retrieved")
|
||||||
|
print(json.dumps(stats, indent=2)[:1000])
|
||||||
|
else:
|
||||||
|
logger.error("❌ Failed to get stats")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Test failed: {str(e)}")
|
||||||
|
# Print detailed exception info
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
print("\n=== Test Complete ===")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,26 +5,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
baseURL string
|
baseURL string
|
||||||
session *Session
|
|
||||||
}
|
|
||||||
|
|
||||||
type Session struct {
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
Cookies []*http.Cookie
|
|
||||||
UserAgent string
|
|
||||||
Authenticated bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GarminActivity struct {
|
type GarminActivity struct {
|
||||||
@@ -34,13 +22,13 @@ type GarminActivity struct {
|
|||||||
ActivityType map[string]interface{} `json:"activityType"`
|
ActivityType map[string]interface{} `json:"activityType"`
|
||||||
Distance float64 `json:"distance"`
|
Distance float64 `json:"distance"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
MaxHR int `json:"maxHR"`
|
MaxHR float64 `json:"maxHR"`
|
||||||
AvgHR int `json:"avgHR"`
|
AvgHR float64 `json:"avgHR"`
|
||||||
AvgPower float64 `json:"avgPower"`
|
AvgPower float64 `json:"avgPower"`
|
||||||
Calories int `json:"calories"`
|
Calories float64 `json:"calories"`
|
||||||
StartLatitude float64 `json:"startLatitude"`
|
StartLatitude float64 `json:"startLatitude"`
|
||||||
StartLongitude float64 `json:"startLongitude"`
|
StartLongitude float64 `json:"startLongitude"`
|
||||||
Steps int `json:"steps"`
|
Steps float64 `json:"steps"`
|
||||||
ElevationGain float64 `json:"elevationGain"`
|
ElevationGain float64 `json:"elevationGain"`
|
||||||
ElevationLoss float64 `json:"elevationLoss"`
|
ElevationLoss float64 `json:"elevationLoss"`
|
||||||
AvgTemperature float64 `json:"avgTemperature"`
|
AvgTemperature float64 `json:"avgTemperature"`
|
||||||
@@ -48,381 +36,89 @@ type GarminActivity struct {
|
|||||||
MaxTemperature float64 `json:"maxTemperature"`
|
MaxTemperature float64 `json:"maxTemperature"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Garmin API client
|
||||||
func NewClient() *Client {
|
func NewClient() *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Jar: nil, // Don't use cookie jar, we'll manage cookies manually
|
|
||||||
},
|
|
||||||
baseURL: "https://connect.garmin.com",
|
|
||||||
session: &Session{
|
|
||||||
Username: os.Getenv("GARMIN_EMAIL"),
|
|
||||||
Password: os.Getenv("GARMIN_PASSWORD"),
|
|
||||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
||||||
},
|
},
|
||||||
|
baseURL: "http://garmin-api:8081",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Login() error {
|
// GetStats retrieves user statistics for a specific date via the Python API service
|
||||||
if c.session.Username == "" || c.session.Password == "" {
|
func (c *Client) GetStats(date string) (map[string]interface{}, error) {
|
||||||
return fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD environment variables required")
|
// Construct request URL
|
||||||
}
|
url := fmt.Sprintf("%s/stats?date=%s", c.baseURL, url.QueryEscape(date))
|
||||||
|
|
||||||
fmt.Printf("DEBUG: Attempting login for user: %s\n", c.session.Username)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
|
||||||
// Add random delay to look more human
|
|
||||||
time.Sleep(time.Duration(rand.Intn(1500)+1000) * time.Millisecond)
|
|
||||||
|
|
||||||
// Step 1: Get the initial login page to establish session
|
|
||||||
loginURL := "https://connect.garmin.com/signin/"
|
|
||||||
req, err := http.NewRequest("GET", loginURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get login page: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
fmt.Printf("DEBUG: Initial login page status: %d\n", resp.StatusCode)
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
// Store cookies
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body)
|
||||||
c.session.Cookies = resp.Cookies()
|
|
||||||
fmt.Printf("DEBUG: Received %d cookies from login page\n", len(c.session.Cookies))
|
|
||||||
|
|
||||||
// Step 2: Submit login credentials
|
|
||||||
loginData := url.Values{}
|
|
||||||
loginData.Set("username", c.session.Username)
|
|
||||||
loginData.Set("password", c.session.Password)
|
|
||||||
loginData.Set("embed", "false")
|
|
||||||
loginData.Set("displayNameRequired", "false")
|
|
||||||
|
|
||||||
// Add another delay before POST
|
|
||||||
time.Sleep(time.Duration(rand.Intn(1500)+1000) * time.Millisecond)
|
|
||||||
|
|
||||||
req, err = http.NewRequest("POST", loginURL, strings.NewReader(loginData.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add extra headers
|
var stats map[string]interface{}
|
||||||
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
||||||
req.Header.Set("Connection", "keep-alive")
|
return nil, err
|
||||||
req.Header.Set("Pragma", "no-cache")
|
|
||||||
req.Header.Set("Cache-Control", "no-cache")
|
|
||||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
||||||
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
|
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
||||||
req.Header.Set("Origin", "https://sso.garmin.com")
|
|
||||||
req.Header.Set("Referer", loginURL)
|
|
||||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
||||||
|
|
||||||
// Add existing cookies
|
|
||||||
for _, cookie := range c.session.Cookies {
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = c.httpClient.Do(req)
|
return stats, nil
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to submit login: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("DEBUG: Login response status: %d\n", resp.StatusCode)
|
|
||||||
fmt.Printf("DEBUG: Login response body: %s\n", string(bodyBytes))
|
|
||||||
|
|
||||||
// Update cookies with login response
|
|
||||||
for _, cookie := range resp.Cookies() {
|
|
||||||
c.session.Cookies = append(c.session.Cookies, cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for successful login indicators
|
|
||||||
bodyStr := string(bodyBytes)
|
|
||||||
if strings.Contains(bodyStr, "error") || strings.Contains(bodyStr, "invalid") {
|
|
||||||
return fmt.Errorf("login failed: %s", bodyStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Get the Garmin Connect session
|
|
||||||
connectURL := "https://connect.garmin.com/modern/"
|
|
||||||
req, err = http.NewRequest("GET", connectURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
||||||
|
|
||||||
// Add all cookies
|
|
||||||
for _, cookie := range c.session.Cookies {
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err = c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to access Garmin Connect: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
fmt.Printf("DEBUG: Garmin Connect access status: %d\n", resp.StatusCode)
|
|
||||||
|
|
||||||
// Update cookies again
|
|
||||||
for _, cookie := range resp.Cookies() {
|
|
||||||
c.session.Cookies = append(c.session.Cookies, cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("DEBUG: Total cookies after login: %d\n", len(c.session.Cookies))
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
c.session.Authenticated = true
|
|
||||||
fmt.Println("DEBUG: Login successful!")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("login failed with status: %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActivities retrieves activities from the Python API wrapper
|
||||||
func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) {
|
func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) {
|
||||||
if !c.session.Authenticated {
|
url := fmt.Sprintf("%s/activities?start=%d&limit=%d", c.baseURL, start, limit)
|
||||||
if err := c.Login(); err != nil {
|
|
||||||
return nil, err
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
}
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
url := fmt.Sprintf("%s/modern/proxy/activity-service/activities/search/activities?start=%d&limit=%d",
|
|
||||||
c.baseURL, start, limit)
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
return nil, err
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
defer resp.Body.Close()
|
||||||
}
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
req.Header.Set("User-Agent", c.session.UserAgent)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
req.Header.Set("Accept", "application/json")
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
// Add cookies
|
|
||||||
for _, cookie := range c.session.Cookies {
|
var activities []GarminActivity
|
||||||
req.AddCookie(cookie)
|
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
// Log cookies being sent
|
|
||||||
fmt.Println("DEBUG: Cookies being sent:")
|
return activities, nil
|
||||||
for _, cookie := range req.Cookies() {
|
|
||||||
fmt.Printf(" %s: %s (Expires: %s)\n",
|
|
||||||
cookie.Name,
|
|
||||||
cookie.Value[:min(3, len(cookie.Value))] + "***",
|
|
||||||
cookie.Expires.Format(time.RFC1123))
|
|
||||||
|
|
||||||
// Check if cookie is expired
|
|
||||||
if !cookie.Expires.IsZero() && cookie.Expires.Before(time.Now()) {
|
|
||||||
fmt.Printf("WARNING: Cookie %s expired at %s\n",
|
|
||||||
cookie.Name,
|
|
||||||
cookie.Expires.Format(time.RFC1123))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
fmt.Printf("DEBUG: HTTP Status: %d\n", resp.StatusCode)
|
|
||||||
fmt.Printf("DEBUG: Response Headers: %v\n", resp.Header)
|
|
||||||
|
|
||||||
// If we get empty response but 200 status, check session expiration
|
|
||||||
if resp.StatusCode == http.StatusOK && resp.ContentLength == 2 {
|
|
||||||
fmt.Println("WARNING: Empty API response with 200 status - checking session validity")
|
|
||||||
c.session.Authenticated = false
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log full response for debugging
|
|
||||||
fmt.Printf("DEBUG: Full API Response (%d bytes):\n", len(bodyBytes))
|
|
||||||
fmt.Println(string(bodyBytes))
|
|
||||||
|
|
||||||
// Check for empty response
|
|
||||||
if len(bodyBytes) == 0 {
|
|
||||||
return nil, fmt.Errorf("API returned empty response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case for empty object
|
|
||||||
if string(bodyBytes) == "{}" {
|
|
||||||
fmt.Println("DEBUG: API returned empty object")
|
|
||||||
return nil, fmt.Errorf("API returned empty object")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try flexible parsing
|
|
||||||
activities, err := parseActivityResponse(bodyBytes)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("DEBUG: Failed to parse activities: %v\n", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("DEBUG: Successfully parsed %d activities\n", len(activities))
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
return activities, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function
|
// Helper function removed - no longer needed
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseActivityResponse handles different API response formats
|
|
||||||
func parseActivityResponse(bodyBytes []byte) ([]GarminActivity, error) {
|
|
||||||
// Try standard ActivityList format
|
|
||||||
type ActivityListResponse struct {
|
|
||||||
ActivityList []GarminActivity `json:"activityList"`
|
|
||||||
}
|
|
||||||
var listResponse ActivityListResponse
|
|
||||||
if err := json.Unmarshal(bodyBytes, &listResponse); err == nil && len(listResponse.ActivityList) > 0 {
|
|
||||||
return listResponse.ActivityList, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct array format
|
|
||||||
var directResponse []GarminActivity
|
|
||||||
if err := json.Unmarshal(bodyBytes, &directResponse); err == nil && len(directResponse) > 0 {
|
|
||||||
return directResponse, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try generic map-based format
|
|
||||||
var genericResponse map[string]interface{}
|
|
||||||
if err := json.Unmarshal(bodyBytes, &genericResponse); err == nil {
|
|
||||||
// Check if we have an "activityList" key
|
|
||||||
if activityList, ok := genericResponse["activityList"].([]interface{}); ok {
|
|
||||||
return convertInterfaceSlice(activityList)
|
|
||||||
}
|
|
||||||
// Check if we have a "results" key
|
|
||||||
if results, ok := genericResponse["results"].([]interface{}); ok {
|
|
||||||
return convertInterfaceSlice(results)
|
|
||||||
}
|
|
||||||
// Check if we have an "activities" key
|
|
||||||
if activities, ok := genericResponse["activities"].([]interface{}); ok {
|
|
||||||
return convertInterfaceSlice(activities)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed to parse
|
|
||||||
return nil, fmt.Errorf("unable to parse API response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertInterfaceSlice converts []interface{} to []GarminActivity
|
|
||||||
func convertInterfaceSlice(items []interface{}) ([]GarminActivity, error) {
|
|
||||||
var activities []GarminActivity
|
|
||||||
for _, item := range items {
|
|
||||||
itemMap, ok := item.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert map to JSON then to GarminActivity
|
|
||||||
jsonData, err := json.Marshal(itemMap)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var activity GarminActivity
|
|
||||||
if err := json.Unmarshal(jsonData, &activity); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
activities = append(activities, activity)
|
|
||||||
}
|
|
||||||
return activities, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// DownloadActivity downloads an activity from Garmin Connect (stub implementation)
|
||||||
func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) {
|
func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) {
|
||||||
if !c.session.Authenticated {
|
return nil, fmt.Errorf("DownloadActivity not implemented - use Python API")
|
||||||
if err := c.Login(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to FIT format
|
|
||||||
if format == "" {
|
|
||||||
format = "fit"
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/modern/proxy/download-service/export/%s/activity/%d",
|
|
||||||
c.baseURL, format, activityID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
||||||
|
|
||||||
// Add cookies
|
|
||||||
for _, cookie := range c.session.Cookies {
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("failed to download activity %d: status %d", activityID, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActivityDetails retrieves details for a specific activity from the Python API wrapper
|
||||||
func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) {
|
func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) {
|
||||||
if !c.session.Authenticated {
|
url := fmt.Sprintf("%s/activities/%d", c.baseURL, activityID)
|
||||||
if err := c.Login(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/modern/proxy/activity-service/activity/%d",
|
|
||||||
c.baseURL, activityID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", c.session.UserAgent)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
// Add cookies
|
|
||||||
for _, cookie := range c.session.Cookies {
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -430,24 +126,14 @@ func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("failed to get activity details: status %d", resp.StatusCode)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
var activity GarminActivity
|
var activity GarminActivity
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract activity type from map if possible
|
|
||||||
if typeKey, ok := activity.ActivityType["typeKey"].(string); ok {
|
|
||||||
activity.ActivityType = map[string]interface{}{"typeKey": typeKey}
|
|
||||||
} else {
|
|
||||||
// Default to empty map if typeKey not found
|
|
||||||
activity.ActivityType = map[string]interface{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
return &activity, nil
|
return &activity, nil
|
||||||
}
|
}
|
||||||
|
|||||||
36
test_login.go
Normal file
36
test_login.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sst/garmin-sync-go/internal/garmin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Set environment variables for testing
|
||||||
|
os.Setenv("GARMIN_EMAIL", "your_email@example.com")
|
||||||
|
os.Setenv("GARMIN_PASSWORD", "your_password")
|
||||||
|
|
||||||
|
client := garmin.NewClient()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
fmt.Println("Starting login test...")
|
||||||
|
|
||||||
|
err := client.Login()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Login failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Login successful! Duration: %v\n", time.Since(start))
|
||||||
|
|
||||||
|
if client.IsAuthenticated() {
|
||||||
|
fmt.Println("Session authenticated:", client.IsAuthenticated())
|
||||||
|
fmt.Println("Number of cookies:", len(client.GetCookies()))
|
||||||
|
} else {
|
||||||
|
fmt.Println("Session not authenticated after successful login")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user