Defines and schedules Garmin workouts
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

285 lines
11 KiB

package com.github.mgifos.workouts
import java.time.LocalDate
import akka.actor.{Actor, ActorRef, ActorSystem, Props}
import akka.http.scaladsl.Http
import akka.http.scaladsl.client.RequestBuilding._
import akka.http.scaladsl.model.ContentTypes._
import akka.http.scaladsl.model.HttpMethods._
import akka.http.scaladsl.model.StatusCodes._
import akka.http.scaladsl.model.Uri.Query
import akka.http.scaladsl.model._
import akka.http.scaladsl.model.headers._
import akka.pattern.ask
import akka.stream.scaladsl.{Flow, Sink, Source}
import akka.stream.{Materializer, ThrottleMode}
import akka.util.Timeout
import com.github.mgifos.workouts.GarminConnect._
import com.github.mgifos.workouts.model.WorkoutDef
import com.typesafe.scalalogging.Logger
import play.api.libs.json.{JsObject, Json}
import scala.collection.immutable.{Map, Seq}
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
import scala.language.implicitConversions
import scala.util.Failure
case class GarminWorkout(name: String, id: Long)
case class GarminSession(headers: Seq[HttpHeader])
class GarminConnect(email: String, password: String)(implicit system: ActorSystem, executionContext: ExecutionContext, mat: Materializer) {
case class Login(forceNewSession: Boolean)
private val log = Logger(getClass)
/**
* Creates workout definitions
*
* @param workouts
* @return
*/
def createWorkouts(workouts: Seq[WorkoutDef])(implicit session: GarminSession): Future[Seq[GarminWorkout]] = {
log.info("\nCreating workouts:")
val source = Source(workouts.map { workout =>
val req = Post("https://connect.garmin.com/modern/proxy/workout-service/workout")
.withEntity(HttpEntity(`application/json`, workout.json().toString()))
.withHeaders(
session.headers
:+ Referer("https://connect.garmin.com/modern/workout/create/running")
:+ RawHeader("NK", "NT"))
workout.name -> req
})
val flow = Flow[(String, HttpRequest)].throttle(1, 1.second, 1, ThrottleMode.shaping).mapAsync(1) {
case (workout, req) =>
Http().singleRequest(req).flatMap { res =>
if (res.status == OK) {
res.body.map { json =>
log.info(s" $workout")
GarminWorkout(workout, Json.parse(json).\("workoutId").as[Long])
}
} else {
log.debug(s"Creation wo response: $res")
Future.failed(new Error("Cannot create workout"))
}
}
}
source.via(flow).runWith(Sink.seq)
}
/**
* Deletes workouts with provided names
*
* @param workouts Workout names
* @return Count of deleted items
*/
def deleteWorkouts(workouts: Seq[String])(implicit session: GarminSession): Future[Int] = {
val futureRequests = getWorkoutsMap().map { wsMap =>
log.info("\nDeleting workouts:")
for {
workout <- workouts
ids = wsMap.getOrElse(workout, Seq.empty)
if ids.nonEmpty
} yield {
val label = s"$workout -> ${ids.mkString("[", ", ", "]")}"
label -> ids.map(
id =>
Post(s"https://connect.garmin.com/modern/proxy/workout-service/workout/$id").withHeaders(
session.headers
:+ Referer("https://connect.garmin.com/modern/workouts")
:+ RawHeader("NK", "NT")
:+ RawHeader("X-HTTP-Method-Override", "DELETE")))
}
}
val source = Source.fromFuture(futureRequests).flatMapConcat(Source(_)).throttle(1, 1.second, 1, ThrottleMode.shaping).mapAsync(1) {
case (label, reqs) =>
val statusesFut = Future.sequence(reqs.map(req => Http().singleRequest(req).withoutBody))
statusesFut.map { statuses =>
if (statuses.forall(_.status == NoContent)) log.info(s" $label")
else log.error(s" Cannot delete workout: $label")
}
}
source.runWith(Sink.seq).map(_.length)
}
def schedule(spec: Seq[(LocalDate, GarminWorkout)])(implicit session: GarminSession): Future[Int] = {
log.debug(s" Scheduling spec: ${spec.mkString("\n")}")
log.info("\nScheduling:")
Source(spec).map {
case (date, gw) =>
s"$date -> ${gw.name}" -> Post(s"https://connect.garmin.com/modern/proxy/workout-service/schedule/${gw.id}")
.withHeaders(session.headers
:+ Referer("https://connect.garmin.com/modern/calendar")
:+ RawHeader("NK", "NT"))
.withEntity(HttpEntity(`application/json`, Json.obj("date" -> date.toString).toString))
}.throttle(1, 1.second, 1, ThrottleMode.shaping)
.mapAsync(1) {
case (label, req) =>
Http().singleRequest(req).withoutBody.map { res =>
log.debug(s" Received $res")
if (res.status == OK) log.info(s" $label")
else log.error(s" Cannot schedule: $label")
}
}
.runWith(Sink.seq)
.map(_.length)
}
/**
* Retrieves workout mapping: name -> Seq[id] @ GarminConnect
* @return
*/
private def getWorkoutsMap()(implicit session: GarminSession): Future[Map[String, Seq[Long]]] = {
val req = Get("https://connect.garmin.com/modern/proxy/workout-service/workouts?start=1&limit=9999&myWorkoutsOnly=true&sharedWorkoutsOnly=false")
.withHeaders(
session.headers
:+ Referer("https://connect.garmin.com/modern/workouts")
:+ RawHeader("NK", "NT"))
val source = Source.fromFuture(Http().singleRequest(req).flatMap { res =>
if (res.status == OK)
res.body.map { json =>
Json
.parse(json)
.asOpt[Seq[JsObject]]
.map { arr =>
arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long])
}
.getOrElse(Seq.empty)
.groupBy { case (name, _) => name }
.map { case (a, b) => a -> b.map(_._2) }
} else {
log.debug(s"Cannot retrieve workout list, response: $res")
Future.failed(new Error("Cannot retrieve workout list from Garmin Connect"))
}
})
source.runWith(Sink.head)
}
private lazy val loginActor: ActorRef = system.actorOf(Props(new LoginActor()))
def login(forceNewSession: Boolean = false): Future[Either[String, GarminSession]] =
ask(loginActor, Login(forceNewSession))(Timeout(2.minutes)).mapTo[Either[String, GarminSession]]
/**
* Holds and reloads session if neccessary
*/
class LoginActor extends Actor {
private case class NewSession(session: GarminSession)
var maybeSession: Option[GarminSession] = None
override def receive = {
case Login(force) =>
val origin = sender()
maybeSession match {
case Some(s) if !force => origin ! s
case _ =>
login.andThen {
case util.Success(x) =>
if (x.headers.exists(_.value().matches("""SESSIONID=[a-z\d-]{5,}"""))) {
origin ! Right(x)
self ! NewSession(x)
} else
origin ! Left("Login was not successful, check your username and password and try again.")
case Failure(_) =>
origin ! Left("Attempt to log in to Garmin Connect was not successful (this could be a server error).")
}
}
case NewSession(session) =>
if (maybeSession.isEmpty) log.info("Successfully logged in to Garmin Connect!")
maybeSession = Option(session)
}
private def login: Future[GarminSession] = {
def extractCookies(res: HttpResponse) = res.headers.collect { case x: `Set-Cookie` => x.cookie }.map(c => Cookie(c.name, c.value))
def redirectionLoop(count: Int, url: String, acc: Seq[Cookie]): Future[Seq[Cookie]] = {
val req = HttpRequest(uri = Uri(url)).withHeaders(acc)
log.debug(s"Login redirection no $count with req: $req")
Http().singleRequest(req).withoutBody.flatMap { res =>
log.debug(s"Res: $res")
val cookies = extractCookies(res)
res.headers.find(_.name() == "Location") match {
case Some(header) =>
if (count < 7) {
val path = header.value()
val base = path.split("/").take(3).mkString("/")
val nextUrl = if (path.startsWith("/")) base + path else path
redirectionLoop(count + 1, nextUrl, acc ++ cookies)
} else {
Future.successful(acc ++ cookies)
}
case None => Future.successful(acc ++ cookies)
}
}
}
val params = Map(
"clientId" -> "GarminConnect",
//"connectLegalTerms" -> "true",
"consumeServiceTicket" -> "false",
//"createAccountShown" -> "true",
//"cssUrl" -> "https://static.garmincdn.com/com.garmin.connect/ui/css/gauth-custom-v1.2-min.css",
//"displayNameShown" -> "false",
//"embedWidget" -> "false",
"gauthHost" -> "https://sso.garmin.com/sso",
//"generateExtraServiceTicket" -> "false",
//"generateNoServiceTicket" -> "false",
//"globalOptInChecked" -> "false",
//"globalOptInShown" -> "true",
//"id" -> "gauth-widget",
//"initialFocus" -> "true",
//"locale" -> "en_US",
//"locationPromptShown" -> "true",
//"mobile" -> "false",
//"openCreateAccount" -> "false",
//"privacyStatementUrl" -> "//connect.garmin.com/en-US/privacy/",
//"redirectAfterAccountCreationUrl" -> "https://connect.garmin.com/modern/",
//"redirectAfterAccountLoginUrl" -> "https://connect.garmin.com/modern/",
//"rememberMeChecked" -> "false",
//"rememberMeShown" -> "true",
"service" -> "https://connect.garmin.com/modern"
//"source" -> "https://connect.garmin.com/en-US/signin",
//"webhost" -> "https://connect.garmin.com"
)
for {
res1 <- Http().singleRequest(HttpRequest(uri = Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)))).withoutBody
res2 <- Http()
.singleRequest(
HttpRequest(
POST,
Uri("https://sso.garmin.com/sso/signin").withQuery(Query(params)),
entity = FormData(Map("username" -> email, "password" -> password, "embed" -> "false")).toEntity
).withHeaders(extractCookies(res1)).withHeaders(Origin("https://sso.garmin.com")))
.withoutBody
sessionCookies <- redirectionLoop(0, "https://connect.garmin.com/modern", extractCookies(res2))
} yield GarminSession(sessionCookies)
}
}
}
object GarminConnect {
class HttpResponseWithBody(original: HttpResponse) {
def body(implicit ec: ExecutionContext, mat: Materializer): Future[String] = original.entity.toStrict(10.seconds).map(_.data.utf8String)
}
class LiteHttpFuture(original: Future[HttpResponse]) {
def withoutBody(implicit ec: ExecutionContext, mat: Materializer) = original.andThen {
case util.Success(res) => res.discardEntityBytes()
}
}
implicit def responseWithBody(original: HttpResponse): HttpResponseWithBody = new HttpResponseWithBody(original)
implicit def liteHttpFuture(original: Future[HttpResponse]): LiteHttpFuture = new LiteHttpFuture(original)
}