From f573e62b2c4798bb5c6ba44bdc031879e1be3e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Mon, 12 Apr 2021 23:13:03 +0200 Subject: [PATCH] Extract user messages --- .../scala/com/kubukoz/next/ConfigLoader.scala | 8 +-- src/main/scala/com/kubukoz/next/Login.scala | 5 +- src/main/scala/com/kubukoz/next/Main.scala | 24 ++++--- src/main/scala/com/kubukoz/next/Program.scala | 22 +++--- src/main/scala/com/kubukoz/next/Spotify.scala | 26 ++++--- .../scala/com/kubukoz/next/UserMessage.scala | 68 +++++++++++++++++++ 6 files changed, 111 insertions(+), 42 deletions(-) create mode 100644 src/main/scala/com/kubukoz/next/UserMessage.scala diff --git a/src/main/scala/com/kubukoz/next/ConfigLoader.scala b/src/main/scala/com/kubukoz/next/ConfigLoader.scala index d95de5ff..263b2661 100644 --- a/src/main/scala/com/kubukoz/next/ConfigLoader.scala +++ b/src/main/scala/com/kubukoz/next/ConfigLoader.scala @@ -33,15 +33,13 @@ object ConfigLoader { } } - def withCreateFileIfMissing[F[_]: Console: MonadThrow](configPath: Path): ConfigLoader[F] => ConfigLoader[F] = { - implicit val showPath: Show[Path] = Show.fromToString + def withCreateFileIfMissing[F[_]: UserOutput: Console: MonadThrow](configPath: Path): ConfigLoader[F] => ConfigLoader[F] = { val validInput = "Y" - val askMessage = show"Didn't find config file at $configPath. Should I create one? ($validInput/n)" def askToCreateFile(originalException: NoSuchFileException): F[Config] = for { - _ <- Console[F].println(askMessage) + _ <- UserOutput[F].print(UserMessage.ConfigFileNotFound(configPath, validInput)) _ <- Console[F].readLine.map(_.trim).ensure(originalException)(_.equalsIgnoreCase(validInput)) clientId <- ConsoleRead.readWithPrompt[F, String]("Client ID") clientSecret <- ConsoleRead.readWithPrompt[F, String]("Client secret") @@ -51,7 +49,7 @@ object ConfigLoader { new ConfigLoader[F] { val loadConfig: F[Config] = underlying.loadConfig.recoverWith { case e: NoSuchFileException => askToCreateFile(e).flatTap(saveConfig) <* - Console[F].println(s"Saved config to new file at $configPath") + UserOutput[F].print(UserMessage.SavedConfig(configPath)) } def saveConfig(config: Config): F[Unit] = underlying.saveConfig(config) diff --git a/src/main/scala/com/kubukoz/next/Login.scala b/src/main/scala/com/kubukoz/next/Login.scala index 8f723287..9644671c 100644 --- a/src/main/scala/com/kubukoz/next/Login.scala +++ b/src/main/scala/com/kubukoz/next/Login.scala @@ -3,7 +3,6 @@ package com.kubukoz.next import cats.effect.kernel.Async import cats.effect.kernel.Deferred import cats.effect.kernel.Resource -import cats.effect.std.Console import cats.implicits._ import com.kubukoz.next.Login.Tokens import com.kubukoz.next.api.spotify.RefreshedTokenResponse @@ -37,7 +36,7 @@ object Login { final case class Code(value: String) - def blaze[F[_]: Console: Config.Ask: Async]( + def blaze[F[_]: UserOutput: Config.Ask: Async]( client: Client[F] ): Login[F] = new Login[F] { @@ -80,7 +79,7 @@ object Login { .withQueryParam("response_type", "code") } .flatMap { uri => - Console[F].println(s"Go to $uri") + UserOutput[F].print(UserMessage.GoToUri(uri)) } def mkServer(config: Config, route: HttpRoutes[F]) = diff --git a/src/main/scala/com/kubukoz/next/Main.scala b/src/main/scala/com/kubukoz/next/Main.scala index cd4383b4..a80045df 100644 --- a/src/main/scala/com/kubukoz/next/Main.scala +++ b/src/main/scala/com/kubukoz/next/Main.scala @@ -5,6 +5,7 @@ import cats.data.NonEmptyList import cats.effect.ExitCode import cats.effect.IO import cats.effect.Resource +import cats.effect.kernel.Async import cats.effect.std.Console import cats.implicits._ import com.kubukoz.next.util.Config @@ -46,32 +47,35 @@ object Main extends CommandIOApp(name = "spotify-next", header = "spotify-next: import Program._ - def runApp[F[_]: Spotify: ConfigLoader: Login: Console: Monad]: Choice => F[Unit] = { + def runApp[F[_]: Spotify: ConfigLoader: Login: UserOutput: Monad]: Choice => F[Unit] = { case Choice.Login => loginUser[F] case Choice.SkipTrack => Spotify[F].skipTrack case Choice.DropTrack => Spotify[F].dropTrack case Choice.FastForward(p) => Spotify[F].fastForward(p) } - val makeProgram: Resource[IO, Choice => IO[Unit]] = + def makeProgram[F[_]: Async: Console]: Resource[F, Choice => F[Unit]] = { + implicit val userOutput: UserOutput[F] = UserOutput.toConsole + Resource - .eval(makeLoader[IO]) + .eval(makeLoader[F]) .flatMap { implicit loader => - implicit val configAsk: Config.Ask[IO] = loader.configAsk + implicit val configAsk: Config.Ask[F] = loader.configAsk - makeBasicClient[IO].evalMap { rawClient => - implicit val login: Login[IO] = Login.blaze[IO](rawClient) + makeBasicClient[F].evalMap { rawClient => + implicit val login: Login[F] = Login.blaze[F](rawClient) - makeSpotify(apiClient[IO].apply(rawClient)).map { implicit spotify => - runApp[IO] + makeSpotify(apiClient[F].apply(rawClient)).map { implicit spotify => + runApp[F] } } } + } val mainOpts: Opts[IO[Unit]] = Choice .opts .map { choice => - makeProgram.use(_.apply(choice)) + makeProgram[IO].use(_.apply(choice)) } val runRepl: IO[Unit] = { @@ -93,7 +97,7 @@ object Main extends CommandIOApp(name = "spotify-next", header = "spotify-next: fs2.Stream.exec(IO.println("Loading REPL...")) ++ fs2 .Stream - .resource(makeProgram) + .resource(makeProgram[IO]) .evalTap(_ => IO.println("Welcome to the spotify-next REPL! Type in a command to begin")) .map(Command("", "")(Choice.opts).map(_)) .flatMap { command => diff --git a/src/main/scala/com/kubukoz/next/Program.scala b/src/main/scala/com/kubukoz/next/Program.scala index c35aeaf8..73ac5a6e 100644 --- a/src/main/scala/com/kubukoz/next/Program.scala +++ b/src/main/scala/com/kubukoz/next/Program.scala @@ -1,8 +1,5 @@ package com.kubukoz.next -import java.lang.System -import java.nio.file.Paths - import cats.Monad import cats.MonadError import cats.effect.Concurrent @@ -24,10 +21,13 @@ import org.http4s.client.middleware.FollowRedirect import org.http4s.client.middleware.RequestLogger import org.http4s.client.middleware.ResponseLogger +import java.lang.System +import java.nio.file.Paths + object Program { val configPath = Paths.get(System.getProperty("user.home")).resolve(".spotify-next.json") - def makeLoader[F[_]: Files: Ref.Make: Console: fs2.Compiler.Target]: F[ConfigLoader[F]] = + def makeLoader[F[_]: Files: Ref.Make: UserOutput: Console: fs2.Compiler.Target]: F[ConfigLoader[F]] = ConfigLoader .cached[F] .compose(ConfigLoader.withCreateFileIfMissing[F](configPath)) @@ -43,7 +43,9 @@ object Program { .map(RequestLogger(logHeaders = true, logBody = true)) .map(ResponseLogger(logHeaders = true, logBody = true)) - def apiClient[F[_]: Console: ConfigLoader: Login: MonadCancelThrow](implicit SC: fs2.Compiler[F, F]): Client[F] => Client[F] = { + def apiClient[F[_]: UserOutput: Console: ConfigLoader: Login: MonadCancelThrow]( + implicit SC: fs2.Compiler[F, F] + ): Client[F] => Client[F] = { implicit val configAsk: Config.Ask[F] = ConfigLoader[F].configAsk implicit val tokenAsk: Token.Ask[F] = Token.askBy(configAsk)(Getter(_.token)) @@ -63,16 +65,16 @@ object Program { } // Do NOT move this into Spotify, it'll vastly increase the range of its responsibilities! - def loginUser[F[_]: Console: Login: ConfigLoader: Monad]: F[Unit] = + def loginUser[F[_]: UserOutput: Login: ConfigLoader: Monad]: F[Unit] = for { tokens <- Login[F].server config <- ConfigLoader[F].loadConfig newConfig = config.copy(token = tokens.access.some, refreshToken = tokens.refresh.some) _ <- ConfigLoader[F].saveConfig(newConfig) - _ <- Console[F].println("Saved token to file") + _ <- UserOutput[F].print(UserMessage.SavedToken) } yield () - def refreshUserToken[F[_]: Console: Login: ConfigLoader: MonadError[*[_], Throwable]]( + def refreshUserToken[F[_]: UserOutput: Login: ConfigLoader: MonadError[*[_], Throwable]]( refreshToken: RefreshToken ): F[Unit] = for { @@ -80,10 +82,10 @@ object Program { config <- ConfigLoader[F].loadConfig newConfig = config.copy(token = newToken.some) _ <- ConfigLoader[F].saveConfig(newConfig) - _ <- Console[F].println("Refreshed token") //todo debug level? + _ <- UserOutput[F].print(UserMessage.RefreshedToken) } yield () - def makeSpotify[F[_]: Console: Concurrent](client: Client[F]): F[Spotify[F]] = { + def makeSpotify[F[_]: UserOutput: Concurrent](client: Client[F]): F[Spotify[F]] = { implicit val theClient = client import org.http4s.syntax.all._ diff --git a/src/main/scala/com/kubukoz/next/Spotify.scala b/src/main/scala/com/kubukoz/next/Spotify.scala index 53e7dbd0..9de2d57a 100644 --- a/src/main/scala/com/kubukoz/next/Spotify.scala +++ b/src/main/scala/com/kubukoz/next/Spotify.scala @@ -4,7 +4,6 @@ import java.time.Duration import cats.data.Kleisli import cats.effect.Concurrent -import cats.effect.std.Console import cats.implicits._ import com.kubukoz.next.api.sonos import com.kubukoz.next.api.spotify.Item @@ -38,13 +37,10 @@ object Spotify { final case class InvalidContext[T](ctx: T) extends Error final case class InvalidItem[T](ctx: T) extends Error - def instance[F[_]: Playback: Client: Console: Concurrent]: Spotify[F] = + def instance[F[_]: Playback: Client: UserOutput: Concurrent]: Spotify[F] = new Spotify[F] { val client = implicitly[Client[F]] - val console = Console[F] - import console._ - private def requirePlaylist[A](player: Player[Option[PlayerContext], A]): F[Player[PlayerContext.playlist, A]] = player .unwrapContext @@ -58,7 +54,7 @@ object Spotify { .flatMap(_.narrowItem[Item.track].liftTo[F]) val skipTrack: F[Unit] = - println("Switching to next track") *> + UserOutput[F].print(UserMessage.SwitchingToNext) *> Playback[F].nextTrack val dropTrack: F[Unit] = @@ -66,7 +62,7 @@ object Spotify { val trackUri = player.item.uri val playlistId = player.context.uri.playlist - println(show"""Removing track "${player.item.name}" ($trackUri) from playlist $playlistId""") *> + UserOutput[F].print(UserMessage.RemovingCurrentTrack(player)) *> skipTrack *> methods.removeTrack[F](trackUri, playlistId).run(client) } @@ -83,11 +79,13 @@ object Spotify { } .flatMap { case (_, desiredProgressPercent) if desiredProgressPercent >= 100 => - println("Too close to song's ending, rewinding to beginning") *> Playback[F].seek(0) + UserOutput[F].print(UserMessage.TooCloseToEnd) *> + Playback[F].seek(0) case (player, desiredProgressPercent) => val desiredProgressMs = desiredProgressPercent * player.item.durationMs / 100 - println(show"Seeking to $desiredProgressPercent%") *> Playback[F].seek(desiredProgressMs) + UserOutput[F].print(UserMessage.Seeking(desiredProgressPercent)) *> + Playback[F].seek(desiredProgressMs) } } @@ -124,8 +122,8 @@ object Spotify { } - def build[F[_]: Concurrent: Console](sonosBaseUrl: Uri, client: Client[F]): F[Playback[F]] = - Console[F].println(show"Checking if Sonos API is available at $sonosBaseUrl...") *> + def build[F[_]: UserOutput: Concurrent](sonosBaseUrl: Uri, client: Client[F]): F[Playback[F]] = + UserOutput[F].print(UserMessage.CheckingSonos(sonosBaseUrl)) *> client .get(sonosBaseUrl / "zones") { case response if response.status.isSuccess => response.as[sonos.SonosZones].map(_.some) @@ -134,13 +132,13 @@ object Spotify { .handleError(_ => None) .flatMap { case None => - Console[F].println("Sonos not found, will access Spotify API directly").as(spotify(client)) + UserOutput[F].print(UserMessage.SonosNotFound).as(spotify(client)) case Some(zones) => val roomName = zones.zones.head.coordinator.roomName - Console[F] - .println(show"Found ${zones.zones.size} zone(s), will use room $roomName") + UserOutput[F] + .print(UserMessage.SonosFound(zones, roomName)) .as(localSonos(sonosBaseUrl, roomName, client)) } diff --git a/src/main/scala/com/kubukoz/next/UserMessage.scala b/src/main/scala/com/kubukoz/next/UserMessage.scala new file mode 100644 index 00000000..3b148334 --- /dev/null +++ b/src/main/scala/com/kubukoz/next/UserMessage.scala @@ -0,0 +1,68 @@ +package com.kubukoz.next + +import cats.Show +import cats.effect.std +import cats.implicits._ +import cats.~> +import com.kubukoz.next.api.sonos.SonosZones +import com.kubukoz.next.api.spotify.Item +import com.kubukoz.next.api.spotify.Player +import com.kubukoz.next.api.spotify.PlayerContext +import org.http4s.Uri + +import java.nio.file.Path + +sealed trait UserMessage extends Product with Serializable + +object UserMessage { + final case class GoToUri(uri: Uri) extends UserMessage + final case class ConfigFileNotFound(path: Path, validInput: String) extends UserMessage + final case class SavedConfig(path: Path) extends UserMessage + case object SavedToken extends UserMessage + case object RefreshedToken extends UserMessage + + // playback + case object SwitchingToNext extends UserMessage + final case class RemovingCurrentTrack(player: Player[PlayerContext.playlist, Item.track]) extends UserMessage + case object TooCloseToEnd extends UserMessage + final case class Seeking(desiredProgressPercent: Int) extends UserMessage + + // setup + final case class CheckingSonos(url: Uri) extends UserMessage + case object SonosNotFound extends UserMessage + final case class SonosFound(zones: SonosZones, roomName: String) extends UserMessage +} + +trait UserOutput[F[_]] { + def print(msg: UserMessage): F[Unit] + def mapK[G[_]](fk: F ~> G): UserOutput[G] = msg => fk(print(msg)) +} + +object UserOutput { + def apply[F[_]](implicit F: UserOutput[F]): UserOutput[F] = F + + def toConsole[F[_]: std.Console]: UserOutput[F] = { + implicit val showPath: Show[Path] = Show.fromToString + + import UserMessage._ + + val stringify: UserMessage => String = { + case GoToUri(uri) => show"Go to $uri" + case ConfigFileNotFound(path, validInput) => show"Didn't find config file at $path. Should I create one? ($validInput/n)" + case SavedConfig(path) => show"Saved config to new file at $path" + case SavedToken => "Saved token to file" + case RefreshedToken => "Refreshed token" + case SwitchingToNext => "Switching to next track" + case RemovingCurrentTrack(player) => + show"""Removing track "${player.item.name}" (${player.item.uri}) from playlist ${player.context.uri.playlist}""" + case TooCloseToEnd => "Too close to song's ending, rewinding to beginning" + case Seeking(desiredProgressPercent) => show"Seeking to $desiredProgressPercent%" + case CheckingSonos(url) => show"Checking if Sonos API is available at $url..." + case SonosNotFound => "Sonos not found, will access Spotify API directly" + case SonosFound(zones, roomName) => show"Found ${zones.zones.size} zone(s), will use room $roomName" + } + + msg => std.Console[F].println(stringify(msg)) + } + +}