parent
cb59f52c26
commit
2481dc9952
@ -0,0 +1,12 @@ |
||||
<configuration> |
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> |
||||
<encoder> |
||||
<pattern>%msg%n</pattern> |
||||
</encoder> |
||||
</appender> |
||||
|
||||
<root level="info"> |
||||
<appender-ref ref="STDOUT" /> |
||||
</root> |
||||
</configuration> |
||||
@ -0,0 +1,264 @@ |
||||
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.{ Materializer, ThrottleMode } |
||||
import akka.stream.scaladsl.{ Flow, Sink, Source } |
||||
import akka.util.Timeout |
||||
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.util.Failure |
||||
|
||||
case class GarminWorkout(name: String, id: Long) |
||||
|
||||
class GarminConnect(email: String, password: String)(implicit system: ActorSystem, executionContext: ExecutionContext, mat: Materializer) { |
||||
|
||||
case class Session(username: String, headers: Seq[HttpHeader]) |
||||
|
||||
case class Login(forceNewSession: Boolean) |
||||
|
||||
private val log = Logger(getClass) |
||||
|
||||
/** |
||||
* Creates workout definitions |
||||
* |
||||
* @param workouts |
||||
* @return |
||||
*/ |
||||
def createWorkouts(workouts: Seq[WorkoutDef]): Future[Seq[GarminWorkout]] = { |
||||
|
||||
val source = Source.fromFuture(login()).flatMapConcat { session => |
||||
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")) |
||||
log.debug(s"Sending req: ${req.httpMessage}") |
||||
workout.name -> req |
||||
}) |
||||
} |
||||
|
||||
val flow = Flow[(String, HttpRequest)] |
||||
.throttle(1, 1.second, 1, ThrottleMode.shaping) |
||||
.mapAsync(1) { |
||||
case (workout, req) => |
||||
for { |
||||
res <- Http().singleRequest(req).andThen { |
||||
case util.Success(ok) => |
||||
log.debug(s"response is ok: $ok") |
||||
case Failure(ex) => |
||||
log.error("Ups", ex) |
||||
} |
||||
if res.status == OK |
||||
json <- res.entity.toStrict(10.seconds).map(_.data.utf8String) |
||||
} yield { |
||||
log.info(s" '$workout'") |
||||
GarminWorkout(workout, Json.parse(json).\("workoutId").as[Long]) |
||||
} |
||||
} |
||||
|
||||
log.info("\nCreating workouts:") |
||||
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]): Future[Int] = { |
||||
|
||||
val futureRequests = for { |
||||
session <- login() |
||||
map <- getWorkoutsMap() |
||||
_ = log.debug(s"MAP: $map") |
||||
pairs = workouts.flatMap { wo => |
||||
map.filter { case (name, _) => name == wo } |
||||
} |
||||
_ = log.debug(s"PAIRS: $pairs") |
||||
} yield { |
||||
log.info("\nDeleting workouts:") |
||||
pairs.map { |
||||
case (workout, id) => |
||||
val label = s"'$workout' -> $id" |
||||
label -> 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, req) => |
||||
log.debug(s" Delete request: $req") |
||||
Http().singleRequest(req).map { res => |
||||
res.discardEntityBytes() |
||||
if (res.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)]): Future[Int] = { |
||||
log.debug(s" Scheduling spec: ${spec.mkString("\n")}") |
||||
Source.fromFuture(login(forceNewSession = true)).flatMapConcat { session => |
||||
log.info("\nScheduling:") |
||||
Source(spec).map { |
||||
case (date, gw) => |
||||
log.debug(s"Making $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) => |
||||
log.debug(s" Sending $req") |
||||
Http().singleRequest(req).map { res => |
||||
log.debug(s" Received $res") |
||||
if (res.status == OK) log.info(s" $label") |
||||
else log.error(s" Cannot schedule: $label") |
||||
res.discardEntityBytes() |
||||
} |
||||
} |
||||
}.runWith(Sink.seq).map(_.length) |
||||
} |
||||
|
||||
/** |
||||
* Retrieves workout mapping: name -> id @ GarminConnect |
||||
* @return |
||||
*/ |
||||
private def getWorkoutsMap(): Future[Seq[(String, Long)]] = { |
||||
val source = Source.fromFuture(login()).flatMapConcat { session => |
||||
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")) |
||||
Source.fromFuture( |
||||
for { |
||||
res <- Http().singleRequest(req) |
||||
if res.status == OK |
||||
json <- res.entity.toStrict(2.seconds).map(_.data.utf8String) |
||||
} yield Json.parse(json).asOpt[Seq[JsObject]].map { arr => |
||||
arr.map(x => (x \ "workoutName").as[String] -> (x \ "workoutId").as[Long]) |
||||
}.getOrElse(Seq.empty)) |
||||
} |
||||
source.runWith(Sink.head) |
||||
} |
||||
|
||||
private lazy val loginActor: ActorRef = system.actorOf(Props(new LoginActor())) |
||||
|
||||
private def login(forceNewSession: Boolean = false): Future[Session] = ask(loginActor, Login(forceNewSession))(Timeout(2.minutes)).mapTo[Session] |
||||
|
||||
/** |
||||
* Holds and reloads session if neccessary |
||||
*/ |
||||
class LoginActor extends Actor { |
||||
|
||||
private case class NewSession(session: Session) |
||||
|
||||
var maybeSession: Option[Session] = 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) => |
||||
origin ! x |
||||
self ! NewSession(x) |
||||
case Failure(_) => |
||||
log.error("Failed to log in to Garmin Connect") |
||||
} |
||||
} |
||||
|
||||
case NewSession(session) => |
||||
if (maybeSession.isEmpty) log.info("Successfully logged in to Garmin Connect") |
||||
maybeSession = Option(session) |
||||
} |
||||
|
||||
private def login: Future[Session] = { |
||||
|
||||
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]] = { |
||||
Http().singleRequest { |
||||
HttpRequest(uri = Uri(url)).withHeaders(acc) |
||||
}.flatMap { res => |
||||
res.discardEntityBytes() |
||||
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( |
||||
"service" -> "https://connect.garmin.com/post-auth/login", |
||||
"clientId" -> "GarminConnect", |
||||
"gauthHost" -> "https://sso.garmin.com/sso", |
||||
"consumeServiceTicket" -> "false") |
||||
for { |
||||
res1 <- Http().singleRequest(HttpRequest(uri = Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)))) |
||||
res2 <- Http().singleRequest( |
||||
HttpRequest( |
||||
POST, |
||||
Uri("https://sso.garmin.com/sso/login").withQuery(Query(params)), |
||||
entity = FormData(Map( |
||||
"username" -> email, |
||||
"password" -> password, |
||||
"_eventId" -> "submit", |
||||
"embed" -> "true")).toEntity).withHeaders(extractCookies(res1))) |
||||
sessionCookies <- redirectionLoop(0, "https://connect.garmin.com/post-auth/login", extractCookies(res2)) |
||||
username <- getUsername(sessionCookies) |
||||
} yield { |
||||
res1.discardEntityBytes() |
||||
res2.discardEntityBytes() |
||||
Session(username, sessionCookies) |
||||
} |
||||
} |
||||
|
||||
private def getUsername(sessionCookies: Seq[HttpHeader]): Future[String] = { |
||||
val req = HttpRequest(GET, Uri("https://connect.garmin.com/user/username")).withHeaders(sessionCookies) |
||||
Http().singleRequest(req).flatMap { res => |
||||
if (res.status != StatusCodes.OK) throw new Error("Login failed!") |
||||
res.entity.toStrict(2.seconds).map(_.data.utf8String).map { json => |
||||
(Json.parse(json) \ "username").as[String] |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue