diff --git a/README.md b/README.md index 78aa3bb..6c82cfe 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ You'll need the following: - An installation of [FitTrackee](https://github.com/SamR1/FitTrackee/) - An OAuth2 application configured for FitTrackee (go to the "Apps" section of your FitTrackee account to configure this) - - The app needs access to the `workouts:read` and `workouts:write` scopes + - The app needs access to the `workouts:read`, `workouts:write`, and + `profile:read` scopes (the last one so we can get the user's timezone for activities without GPS data) - You'll need to use the "Id" and "Secret" (only showed at creation time) to configure this script, so make sure to write them down - When you create your app, the Application URL and Redirect URL can be pretty @@ -58,10 +59,27 @@ is often hosted with a self-signed certificate). Install the script's dependencies by running: ```sh -$ poetry install --no-root +$ poetry install ``` -This will create new virtual environment and install the dependencies for the script. +This will create new virtual environment and install the module and script. + +## Updating + +If you would like to update your version of the script, pull the latest code from git, +and re-run the poetry install command in case there were any new dependencies added: + +```sh +$ git pull +$ poetry install +``` + +*Note*: This happens infrequently, but in the event the script needs new permissions +(e.g. v0.2.0 required a new `profile:read` permission), you will need to re-run the +application authorization flow. The best way to do this is to delete and re-create the +API application you created in the FitTrackee web interface (making sure to use the new +permissions required), delete the local `.fittrackee.tokens.json` file, and update your +`.env` file to use the updated client ID and client secret from the new API app. ## Usage @@ -73,36 +91,37 @@ by Poetry. Run the following from the directory you downloaded the files to in o see the "help" output of the script, which will show what it can do: ```sh -$ poetry run python s2f.py -h +$ poetry run python -m strava_to_fittrackee.s2f -h ``` ``` -usage: s2f.py [-h] [-v {0,1,2}] (--setup-tokens | --sync | --download-all-strava | --upload-all-fittrackee | --delete-all-fittrackee | --upload-gpx GPX_FILE) +usage: s2f.py [-h] [-v {0,1,2}] (-V | --setup-tokens | --sync | --download-all-strava | --upload-all-fittrackee | --delete-all-fittrackee | --upload-gpx GPX_FILE) [--output-folder OUTPUT_FOLDER] [--input-folder INPUT_FOLDER] -This tool provides functionality to download activities from -a Strava "athlete" and upload them as workouts to a FitTrackee -instance (see README.md for more details) +This tool provides functionality to download activities from a Strava "athlete" and +upload them as workouts to a FitTrackee instance (see README.md for more details) Examples (in your terminal): - $ python s2f.py --sync - $ python s2f.py --download-all-strava --output-folder - $ python s2f.py --upload-all-fittrackee --input-folder - $ python s2g.py --delete-all-fittrackee + $ python -m strava_to_fittrackee.s2f --sync + $ python -m strava_to_fittrackee.s2f --download-all-strava --output-folder + $ python -m strava_to_fittrackee.s2f --upload-all-fittrackee --input-folder + $ python -m strava_to_fittrackee.s2f --delete-all-fittrackee -Copyright (c) 2022, Joshua Taillon +Copyright (c) 2022-2023, Joshua Taillon +v0.2.0 optional arguments: -h, --help show this help message and exit -v {0,1,2}, --verbosity {0,1,2} increase output verbosity (default: 1) - --setup-tokens Setup initial token authentication for both Strava and FitTrackee. This will be done automatically if they are not already set up, but this - option allows you to do that without performing any other actions + -V, --version display the program's version + --setup-tokens Setup initial token authentication for both Strava and FitTrackee. This will be done automatically if they are not already set up, but this option allows you to + do that without performing any other actions --sync Download activities from Strava not currently present in FitTrackee and upload them to the FitTrackee instance --download-all-strava Download and store all Strava activities as GPX files in the given folder (default: "./gpx/", but can be changed with "--output-folder" option) --upload-all-fittrackee - Upload all GPX files in the given folder as workouts to the configured FitTrackee instance. (default folder is "./gpx/", but can be configured - with the "--input-folder" option) + Upload all GPX files in the given folder as workouts to the configured FitTrackee instance. (default folder is "./gpx/", but can be configured with the "--input- + folder" option) --delete-all-fittrackee Delete all workouts in the configured FitTrackee instance --upload-gpx GPX_FILE @@ -119,7 +138,7 @@ To get started with the script, you'll need to authorize your "API apps" (create for both Strava and FitTrackee. To do this, run the script with the `--setup-tokens` option: ```sh -$ poetry run python s2f.py --setup-tokens +$ poetry run python -m strava_to_fittrackee.s2f --setup-tokens ``` This will first present you (in the terminal) with a link to Strava, which (after logging in) @@ -160,7 +179,7 @@ need to go through the URL authorization again if you delete or rename the token Once your tokens are set up, run the script with the `--sync` option: ``` -$ poetry run python s2f.py --sync +$ poetry run python -m strava_to_fittrackee.s2f --sync ``` This will check your FitTrackee account for the last workout, and then fetch @@ -216,7 +235,7 @@ default location (run `$ which poetry` to find the correct executable location i yours is different): ``` -5,35 * * * * cd /home/user/s2f && /home/user/.local/bin/poetry run python s2f.py --sync -v 2 >> /home/user/s2f.log 2>&1 +5,35 * * * * cd /home/user/s2f && /home/user/.local/bin/poetry run python -m strava_to_fittrackee.s2f --sync -v 2 >> /home/user/s2f.log 2>&1 ``` By changing to the `s2f` directory and running the script via `$ poetry run python s2f.py`, @@ -229,14 +248,14 @@ limitations described above), if you'd ever like to bulk export activities from and save the resulting GPX files to disk, use the `--download-all-strava` option: ```sh -$ poetry run python s2f.py --download-all-strava +$ poetry run python -m strava_to_fittrackee.s2f --download-all-strava ``` By default, this will download all your activities into a subfolder named `./gpx`, but this location can be changed with the `--output-folder` option, *e.g.*: ```sh -$ poetry run python s2f.py --download-all-strava --output-folder /home/user/Downloads/ +$ poetry run python -m strava_to_fittrackee.s2f --download-all-strava --output-folder /home/user/Downloads/ ``` You will likely run up against the Strava API limits with this method as well, and so @@ -263,7 +282,7 @@ that will upload a single GPX file to the FitTrackee instance. Provide the path file after the `--upload-gpx` option as follows: ```sh -$ poetry run python s2f.py --upload-gpx ./gpx/test_file.gpx +$ poetry run python -m strava_to_fittrackee.s2f --upload-gpx ./gpx/test_file.gpx ``` ### Delete all FitTrackee workouts @@ -273,7 +292,7 @@ the API to delete *all* workouts from the instance. The script will ask you to c this is really what you want to do before actually doing it: ```sh -$ poetry run python s2f.py --delete-all-fittrackee -v 2 +$ poetry run python -m strava_to_fittrackee.s2f --delete-all-fittrackee -v 2 ``` ``` DEBUG:s2f:Initializing FitTrackeeConnector diff --git a/poetry.lock b/poetry.lock index 08f63b4..5b6df90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -140,6 +140,14 @@ python-versions = ">=3.7" [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "pytz" +version = "2023.3" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "requests" version = "2.28.1" @@ -222,7 +230,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "33e38df53f323a134f0f57172a3de8e17b234144c70c7252ccf3e834ca18c36a" +content-hash = "3ca1893ea8f819bb410c686b8e9d59187c114b6e3b6ea2c8a6520c35520ff58e" [metadata.files] black = [ @@ -295,6 +303,10 @@ python-dotenv = [ {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"}, {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, ] +pytz = [ + {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, + {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, +] requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, diff --git a/pyproject.toml b/pyproject.toml index 3928c58..403e94f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "strava-to-fittrackee" -version = "0.1.0" +version = "0.2.0" description = "Pull workouts from Strava and upload to FitTrackee" authors = ["Joshua Taillon "] license = "MIT" @@ -14,6 +14,7 @@ requests-oauthlib = "^1.3.1" python-dotenv = "^0.21.0" gpxpy = "^1.5.0" tqdm = "^4.64.1" +pytz = "^2023.3" [tool.poetry.group.dev.dependencies] diff --git a/s2f.py b/strava_to_fittrackee/s2f.py similarity index 86% rename from s2f.py rename to strava_to_fittrackee/s2f.py index 1b5b7f5..09b2b39 100644 --- a/s2f.py +++ b/strava_to_fittrackee/s2f.py @@ -1,31 +1,31 @@ """ -This tool provides functionality to download activities from -a Strava "athlete" and upload them as workouts to a FitTrackee -instance (see README.md for more details) +This tool provides functionality to download activities from a Strava "athlete" and +upload them as workouts to a FitTrackee instance (see README.md for more details) Examples (in your terminal): - $ python s2f.py --sync - $ python s2f.py --download-all-strava --output-folder - $ python s2f.py --upload-all-fittrackee --input-folder - $ python s2g.py --delete-all-fittrackee + $ python -m strava_to_fittrackee.s2f --sync + $ python -m strava_to_fittrackee.s2f --download-all-strava --output-folder + $ python -m strava_to_fittrackee.s2f --upload-all-fittrackee --input-folder + $ python -m strava_to_fittrackee.s2f --delete-all-fittrackee -Copyright (c) 2022, Joshua Taillon +Copyright (c) 2022-2023, Joshua Taillon """ import argparse import atexit import csv import json +import importlib.metadata import logging import os -import subprocess import tempfile import time from datetime import datetime, timedelta from email.utils import parsedate_to_datetime from pathlib import Path -from typing import Optional, Union +from typing import Any, Dict, List, Optional, Union import gpxpy +import pytz import urllib3 from dotenv import load_dotenv from requests import Response @@ -39,6 +39,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) script_dir = Path(__file__).parent +__version__ = importlib.metadata.version("strava_to_fittrackee") def setup_logging(level: int = 2): level_map = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG} @@ -77,7 +78,7 @@ def setup_tempdir(): def cmdline_args(): # Make parser object p = argparse.ArgumentParser( - description=__doc__, + description=__doc__ + f"v{__version__}", formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -91,6 +92,14 @@ def cmdline_args(): ) group1 = p.add_mutually_exclusive_group(required=True) + + group1.add_argument( + "-V", + "--version", + help="display the program's version", + action="store_true" + ) + group1.add_argument( "--setup-tokens", help=( @@ -380,6 +389,21 @@ def get_activities( activities = r.json() return activities + + def filter_response_by_key( + self, + response: List[Dict], + type_key: str, + null_return: Any + ): + if not response: + return null_return + + response = list(filter(lambda d: d['type'] == type_key, response)) + + return response[0]['data'] if response else null_return + + def create_activity_from_strava(self, activity: dict, get_streams: bool = True): activity_id = activity["id"] if activity["manual"] and get_streams: @@ -395,7 +419,8 @@ def create_activity_from_strava(self, activity: dict, get_streams: bool = True): params={"keys": ["latlng"]}, ) custom_raise_for_status(r) - latlng = r.json()[0]["data"] + latlng = self.filter_response_by_key(r.json(), 'latlng', [(None, None)]) + distance = self.filter_response_by_key(r.json(), 'distance', [0]) logger.debug(f"Getting timepoints for activity {activity_id}") r = self.client.get( @@ -403,7 +428,7 @@ def create_activity_from_strava(self, activity: dict, get_streams: bool = True): params={"keys": ["time"]}, ) custom_raise_for_status(r) - time_list = r.json()[1]["data"] + time_list = self.filter_response_by_key(r.json(), 'time', [0]) logger.debug(f"Getting altitude for activity {activity_id}") r = self.client.get( @@ -411,7 +436,8 @@ def create_activity_from_strava(self, activity: dict, get_streams: bool = True): params={"keys": ["altitude"]}, ) custom_raise_for_status(r) - altitude = r.json()[1]["data"] + altitude = self.filter_response_by_key(r.json(), 'altitude', [None]) + logger.debug(f"Getting velocity for activity {activity_id}") r = self.client.get( @@ -419,7 +445,7 @@ def create_activity_from_strava(self, activity: dict, get_streams: bool = True): params={"keys": ["velocity_smooth"]}, ) custom_raise_for_status(r) - velocity = r.json()[0]["data"] + velocity = self.filter_response_by_key(r.json(), 'velocity_smooth', [None]) else: latlng = [(None, None)] time_list = [0] @@ -432,11 +458,12 @@ def create_activity_from_strava(self, activity: dict, get_streams: bool = True): time_list=time_list, altitude=altitude, velocity=velocity, + distance=distance, ) class Activity: - def __init__(self, activity_dict, latlng, time_list, altitude, velocity): + def __init__(self, activity_dict, latlng, time_list, altitude, velocity, distance): self.title = activity_dict["name"] self.activity_dict = activity_dict self.start_time = datetime.strptime( @@ -447,6 +474,9 @@ def __init__(self, activity_dict, latlng, time_list, altitude, velocity): self.time = [(self.start_time + timedelta(seconds=t)) for t in time_list] self.altitude = altitude self.velocity = velocity + self.distance = distance + self.type = activity_dict["type"] + self.link = f"https://strava.com/activities/{activity_dict['id']}" def as_gpx(self) -> gpxpy.gpx.GPX: """ @@ -458,9 +488,9 @@ def as_gpx(self) -> gpxpy.gpx.GPX: # Create first track in our GPX: gpx_track = gpxpy.gpx.GPXTrack( - name=self.title, description=self.activity_dict["type"] + name=self.title, description=self.type ) - gpx_track.link = f"https://strava.com/activities/{self.activity_dict['id']}" + gpx_track.link = self.link gpx.tracks.append(gpx_track) # Create first segment in our GPX track: @@ -488,7 +518,6 @@ class FitTrackeeConnector: two should be refactored into sub-classes of one common base, but that is more work than I care to do upon initial writing. """ - def __init__(self, verify=False): logger.debug("Initializing FitTrackeeConnector") self.tokens = load_conf("FITTRACKEE_TOKEN_FILE") @@ -501,6 +530,26 @@ def __init__(self, verify=False): self.token_url = self.base_url + "/oauth/token" self.client = self.auth() self.sports = None + self.timezone = None + + # Mapping from Strava activity types to FitTrackee workout sport id values + # use first sport id if we don't have a description + # (will be wrong, but better than error) + self.sport_id_map = { + None: 1, + "Ride": self.get_sport_id("Cycling (Sport)"), + "VirtualRide": self.get_sport_id("Cycling (Virtual)"), + "Hike": self.get_sport_id("Hiking"), + "Walk": self.get_sport_id("Walking"), + "MountainBikeRide": self.get_sport_id("Mountain Biking"), + "EMountainBikeRide": self.get_sport_id("Mountain Biking (Electric)"), + "Rowing": self.get_sport_id("Rowing"), + "Run": self.get_sport_id("Running"), + "AlpineSki": self.get_sport_id("Skiing (Alpine)"), + "NordicSki": self.get_sport_id("Skiing (Cross Country)"), + "Snowshoe": self.get_sport_id("Snowshoes"), + "TrailRun": self.get_sport_id("Trail"), + } def auth(self): """ @@ -520,7 +569,7 @@ def auth(self): def web_application_flow(self): logger.debug("Running FitTrackee Web Application Flow") redirect_uri = f"https://self.host/callback" - scope = "workouts:read workouts:write" + scope = "workouts:read workouts:write profile:read" oauth = OAuth2Session(self.client_id, redirect_uri=redirect_uri, scope=scope) authorization_url, state = oauth.authorization_url(self.authorize_url) print(f"\nPlease go to {authorization_url} and authorize access.") @@ -616,6 +665,15 @@ def get_sport_id(self, sport_name: str) -> Union[int, None]: return sport_dict[0]["id"] else: return None + + def get_user_timezone(self, force_update=False): + """Get the user timezone from the API and store it as attribute.""" + if self.timezone is None or force_update: + r = self.client.get(self.base_url + "/auth/profile", verify=self.verify) + r.raise_for_status() + self.timezone = r.json()['data']['timezone'] + + return self.timezone def upload_gpx(self, gpx_file: Union[str, Path]): """ @@ -654,23 +712,8 @@ def upload_gpx(self, gpx_file: Union[str, Path]): # Mapping from Strava activity types to FitTrackee workout sport id values # use first sport id if we don't have a description # (will be wrong, but better than error) - sport_id_map = { - None: 1, - "Ride": self.get_sport_id("Cycling (Sport)"), - "VirtualRide": self.get_sport_id("Cycling (Virtual)"), - "Hike": self.get_sport_id("Hiking"), - "Walk": self.get_sport_id("Walking"), - "MountainBikeRide": self.get_sport_id("Mountain Biking"), - "EMountainBikeRide": self.get_sport_id("Mountain Biking (Electric)"), - "Rowing": self.get_sport_id("Rowing"), - "Run": self.get_sport_id("Running"), - "AlpineSki": self.get_sport_id("Skiing (Alpine)"), - "NordicSki": self.get_sport_id("Skiing (Cross Country)"), - "Snowshoe": self.get_sport_id("Snowshoes"), - "TrailRun": self.get_sport_id("Trail"), - } data = { - "sport_id": sport_id_map[activity_type], + "sport_id": self.sport_id_map[activity_type], "notes": ( "Uploaded with Strava-to-FitTrackee\nOriginal activity type" f' on Strava was "{activity_type}"' @@ -701,6 +744,48 @@ def upload_gpx(self, gpx_file: Union[str, Path]): r.raise_for_status() + def upload_no_gpx(self, activity: Activity): + """ + POST a workout to the FitTrackee API without any GPS data (manual activity) + https://samr1.github.io/FitTrackee/api/workouts.html#post--api-workouts + """ + activity_type = activity.type + url = activity.link + if not activity_type: + logger.warning( + "Did not find activity type; will use sport_id = 1 which might" + " be incorrect" + ) + + # need to localize activity.start_time, which is in UTC + tz = pytz.timezone(self.get_user_timezone()) + workout_dt = pytz.UTC.localize(activity.start_time).astimezone(tz) + workout_date = workout_dt.strftime("%Y-%m-%d %H:%M") + + data = { + "sport_id": self.sport_id_map[activity_type], + "notes": ( + "Uploaded with Strava-to-FitTrackee\nOriginal activity type" + f' on Strava was "{activity_type}"' + ), + "title": activity.title, + "distance": activity.distance[-1] / 1000.0, + "duration": (activity.time[-1] - activity.time[0]).seconds, + "workout_date": workout_date + } + + if url: + data["notes"] += f"\nOriginal Strava link: {url}" + + logger.debug(f"POSTing workout with no GPX to FitTrackee") + r = self.client.post( + self.base_url + "/workouts/no_gpx", + json=data, + verify=self.verify, + ) + r.raise_for_status() + + def wait_until_fifteen(): """Will sleep the thread until the next 15 minute interval""" now = datetime.now() @@ -887,15 +972,19 @@ def sync_strava_with_fittrackee(): # generate GPX and upload to FitTrackee logger.debug(f'Processing Strava activity {a["id"]}') act = strava.create_activity_from_strava(a, get_streams=True) - temp_file = tempdir.name + f'/{act.activity_dict["id"]}.gpx' - logger.debug(f"Writing Strava activity gpx to {temp_file}") - with open(temp_file, "w") as f: - f.write(act.as_xml()) - logger.info( - f"Uploading workout {i+1} of {len(to_process)} to FitTrackee" - ) - logger.debug(f"Uploading {temp_file} to FitTrackee") - fittrackee.upload_gpx(temp_file) + if act.lat == [None] and act.long == [None]: + # we don't have any GPS data, so do manual activity + fittrackee.upload_no_gpx(act) + else: + temp_file = tempdir.name + f'/{act.activity_dict["id"]}.gpx' + logger.debug(f"Writing Strava activity gpx to {temp_file}") + with open(temp_file, "w") as f: + f.write(act.as_xml()) + logger.info( + f"Uploading workout {i+1} of {len(to_process)} to FitTrackee" + ) + logger.debug(f"Uploading {temp_file} to FitTrackee") + fittrackee.upload_gpx(temp_file) i += 1 except TooManyRequestsError: logger.warning( @@ -926,6 +1015,8 @@ def ask_user_to_confirm(): if __name__ == "__main__": args = cmdline_args() + if args.version: + print(f"strava-to-fittrackee v{__version__}") setup_logging(args.verbosity) check_for_running_instance() if args.setup_tokens: