diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf new file mode 100644 index 0000000..6fe9450 --- /dev/null +++ b/src/main/resources/application.conf @@ -0,0 +1,4 @@ +akka { + stdout-loglevel = "OFF" + loglevel = "OFF" +} \ No newline at end of file diff --git a/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala index 7db18f9..0ecbb9e 100644 --- a/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala +++ b/src/main/scala/com.github.mgifos.workouts/GarminConnect.scala @@ -28,9 +28,9 @@ 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 GarminSession(headers: Seq[HttpHeader]) - case class Session(headers: Seq[HttpHeader]) +class GarminConnect(email: String, password: String)(implicit system: ActorSystem, executionContext: ExecutionContext, mat: Materializer) { case class Login(forceNewSession: Boolean) @@ -42,20 +42,17 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste * @param workouts * @return */ - def createWorkouts(workouts: Seq[WorkoutDef]): Future[Seq[GarminWorkout]] = { - - val source = Source.fromFuture(login()).flatMapConcat { session => - log.info("\nCreating workouts:") - 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 - }) - } + 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) { @@ -72,7 +69,6 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste } } } - source.via(flow).runWith(Sink.seq) } @@ -82,16 +78,13 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste * @param workouts Workout names * @return Count of deleted items */ - def deleteWorkouts(workouts: Seq[String]): Future[Int] = { + def deleteWorkouts(workouts: Seq[String])(implicit session: GarminSession): Future[Int] = { - val futureRequests = for { - session <- login() - map <- getWorkoutsMap() - } yield { + val futureRequests = getWorkoutsMap().map { wsMap => log.info("\nDeleting workouts:") for { workout <- workouts - ids = map.getOrElse(workout, Seq.empty) + ids = wsMap.getOrElse(workout, Seq.empty) if ids.nonEmpty } yield { val label = s"$workout -> ${ids.mkString("[", ", ", "]")}" @@ -115,70 +108,69 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste source.runWith(Sink.seq).map(_.length) } - def schedule(spec: Seq[(LocalDate, GarminWorkout)]): Future[Int] = { + def schedule(spec: Seq[(LocalDate, GarminWorkout)])(implicit session: GarminSession): 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) => - 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) + 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(): Future[Map[String, Seq[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( - 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")) + 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())) - private def login(forceNewSession: Boolean = false): Future[Session] = ask(loginActor, Login(forceNewSession))(Timeout(2.minutes)).mapTo[Session] + 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: Session) + private case class NewSession(session: GarminSession) - var maybeSession: Option[Session] = None + var maybeSession: Option[GarminSession] = None override def receive = { @@ -189,19 +181,22 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste case _ => login.andThen { case util.Success(x) => - origin ! x - self ! NewSession(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(_) => - log.error("Failed to log in to Garmin Connect") + 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") + if (maybeSession.isEmpty) log.info("Successfully logged in to Garmin Connect!") maybeSession = Option(session) } - private def login: Future[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)) @@ -265,7 +260,7 @@ class GarminConnect(email: String, password: String)(implicit system: ActorSyste "password" -> password, "embed" -> "false")).toEntity).withHeaders(extractCookies(res1))).withoutBody sessionCookies <- redirectionLoop(0, "https://connect.garmin.com/modern", extractCookies(res2)) - } yield Session(sessionCookies) + } yield GarminSession(sessionCookies) } } } diff --git a/src/main/scala/com.github.mgifos.workouts/Main.scala b/src/main/scala/com.github.mgifos.workouts/Main.scala index 2c2e115..3b4e03c 100644 --- a/src/main/scala/com.github.mgifos.workouts/Main.scala +++ b/src/main/scala/com.github.mgifos.workouts/Main.scala @@ -6,8 +6,7 @@ import java.time.LocalDate import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.stream.ActorMaterializer -import com.github.mgifos.workouts.model._ -import com.github.mgifos.workouts.model.WeeklyPlan +import com.github.mgifos.workouts.model.{ WeeklyPlan, _ } import com.typesafe.scalalogging.Logger import scopt.OptionParser @@ -46,7 +45,7 @@ object Main extends App { val worker = run(config).andThen { case _ => shutdown() - log.info("Logged out and closed connection") + log.info("Logged out. Connection is closed.") } Await.result(worker, 10.minutes) log.info("Bye") @@ -123,36 +122,43 @@ object Main extends App { val workouts = plan.workouts.toIndexedSeq - for { - maybeDeleteMessage <- deleteWorkoutsTask(workouts.map(_.name)) - maybeGarminWorkouts <- createWorkoutsTask(workouts) - maybeScheduleMessage <- scheduleTask(maybeGarminWorkouts.fold(Seq.empty[GarminWorkout])(identity)) - } yield { - log.info("\nStatistics:") - maybeDeleteMessage.foreach(msg => log.info(" " + msg)) - maybeGarminWorkouts.foreach(workouts => log.info(s" ${workouts.length} imported")) - maybeScheduleMessage.foreach(msg => log.info(" " + msg)) + garmin.login().flatMap { + case Right(s) => + implicit val session: GarminSession = s + for { + maybeDeleteMessage <- deleteWorkoutsTask(workouts.map(_.name)) + maybeGarminWorkouts <- createWorkoutsTask(workouts) + maybeScheduleMessage <- scheduleTask(maybeGarminWorkouts.fold(Seq.empty[GarminWorkout])(identity)) + } yield { + log.info("\nStatistics:") + maybeDeleteMessage.foreach(msg => log.info(" " + msg)) + maybeGarminWorkouts.foreach(workouts => log.info(s" ${workouts.length} imported")) + maybeScheduleMessage.foreach(msg => log.info(" " + msg)) + } + case Left(loginFailureMessage) => + log.error(loginFailureMessage) + Future.successful(()) } } /** * Deletes existing workouts with the same names or not */ - private def deleteWorkoutsTask(workouts: Seq[String])(implicit config: Config, garmin: GarminConnect): Future[Option[String]] = { + private def deleteWorkoutsTask(workouts: Seq[String])(implicit config: Config, garmin: GarminConnect, session: GarminSession): Future[Option[String]] = { if (config.delete) garmin.deleteWorkouts(workouts).map(c => Some(s"$c deleted")) else Future.successful(None) } - private def createWorkoutsTask(workouts: Seq[WorkoutDef])(implicit config: Config, garmin: GarminConnect): Future[Option[Seq[GarminWorkout]]] = { + private def createWorkoutsTask(workouts: Seq[WorkoutDef])(implicit config: Config, garmin: GarminConnect, session: GarminSession): Future[Option[Seq[GarminWorkout]]] = { if (config.mode.exists(Seq(Modes.`import`, Modes.schedule).contains)) garmin.createWorkouts(workouts).map(Option.apply) else Future.successful(None) } - private def scheduleTask(workouts: Seq[GarminWorkout])(implicit config: Config, garmin: GarminConnect, plan: WeeklyPlan): Future[Option[String]] = { + private def scheduleTask(workouts: Seq[GarminWorkout])(implicit config: Config, garmin: GarminConnect, plan: WeeklyPlan, session: GarminSession): Future[Option[String]] = { if (config.mode.contains(Modes.schedule)) {